Add answer-first self-serve search UX
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
# Sprint 20260307-004 - FE Self-Serve Search Answer-First
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Turn global search into an answer-first operator surface instead of a link list by rendering a grounded answer or explicit fallback for every query.
|
||||||
|
- Add a page-owned self-serve contract so each page can define common questions and clarifying prompts without hardcoding logic inside `GlobalSearchComponent`.
|
||||||
|
- Keep phase 1 frontend-only: compose the new experience from route context, recent page actions, unified search cards, and existing synthesis payloads.
|
||||||
|
- Working directory: `src/Web/StellaOps.Web`.
|
||||||
|
- Expected evidence: Angular unit tests, Playwright behavioral coverage, updated UI/AdvisoryAI docs, and sprint execution log entries.
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on:
|
||||||
|
- `docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md`
|
||||||
|
- `docs/implplan/SPRINT_20260306_002_FE_search_advisory_quality_ux.md`
|
||||||
|
- This sprint intentionally avoids backend contract changes so FE can ship a self-serve shell while backend orchestration work proceeds separately.
|
||||||
|
- Safe parallelism:
|
||||||
|
- Docs updates can run once the self-serve contract is frozen.
|
||||||
|
- Additional page rollouts can happen in parallel after the core answer panel structure is stable.
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `docs/modules/ui/architecture.md`
|
||||||
|
- `docs/modules/ui/search-chip-context-contract.md`
|
||||||
|
- `docs/modules/ui/implementation_plan.md`
|
||||||
|
- `docs/modules/advisory-ai/knowledge-search.md`
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
### FE-SELF-001 - Page-owned self-serve contract
|
||||||
|
Status: DONE
|
||||||
|
Dependency: none
|
||||||
|
Owners: Developer (FE), UX
|
||||||
|
Task description:
|
||||||
|
- Extend the shared search context registry so page teams can define self-serve questions and clarification prompts alongside existing chip definitions.
|
||||||
|
- Keep the contract deterministic, bounded, and explicit about page ownership so future route teams can add self-serve behavior without touching ranking internals.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Shared FE contract supports page-owned common questions and clarifying questions.
|
||||||
|
- [x] Runtime composition can blend page definitions with recent-action context without unbounded state.
|
||||||
|
- [x] Contract is documented for future page teams.
|
||||||
|
|
||||||
|
### FE-SELF-002 - Answer-first search panel
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-SELF-001
|
||||||
|
Owners: Developer (FE), UX
|
||||||
|
Task description:
|
||||||
|
- Add a visible answer panel above results for every non-empty search.
|
||||||
|
- The panel must render one of three explicit states:
|
||||||
|
- grounded answer
|
||||||
|
- clarification needed
|
||||||
|
- insufficient evidence
|
||||||
|
- Frame copy by the shared `Find` / `Explain` / `Act` mode so the same evidence reads differently depending on operator intent.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Query responses always render an answer-state panel before result cards or rescue guidance.
|
||||||
|
- [x] Grounded answers expose evidence summary and visible source references.
|
||||||
|
- [x] Clarification and insufficient-evidence states are explicit and non-empty.
|
||||||
|
|
||||||
|
### FE-SELF-003 - Common questions and follow-up questions
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-SELF-001
|
||||||
|
Owners: Developer (FE), UX
|
||||||
|
Task description:
|
||||||
|
- Surface page-owned common questions in the empty state and mode-aware follow-up questions after results.
|
||||||
|
- Blend route context with last-page actions so questions feel tied to what the operator was just doing.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Empty-state search shows page-specific common questions when available.
|
||||||
|
- [x] Result-state answer panel shows follow-up or clarifying questions derived from page context.
|
||||||
|
- [x] Question clicks deterministically rerun search with the exact selected query text.
|
||||||
|
|
||||||
|
### FE-SELF-004 - Verification and behavior hardening
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-SELF-002
|
||||||
|
Owners: Test Automation, QA, Developer (FE)
|
||||||
|
Task description:
|
||||||
|
- Add targeted unit coverage for answer-state selection, question generation, and fallback behavior.
|
||||||
|
- Add Playwright coverage for grounded-answer, clarification, and no-evidence journeys end to end.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Unit coverage validates answer-state selection and contextual question generation.
|
||||||
|
- [x] Playwright covers grounded-answer, clarify, and fallback flows.
|
||||||
|
- [x] Tests remain deterministic with route mocks and no live network dependencies.
|
||||||
|
|
||||||
|
### FE-SELF-005 - Docs sync and rollout guidance
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-SELF-001
|
||||||
|
Owners: Documentation author, Project Manager
|
||||||
|
Task description:
|
||||||
|
- Update UI and AdvisoryAI docs so the new self-serve search contract is explicit.
|
||||||
|
- Record what phase 1 does in FE and what remains for later backend grounding work.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] UI docs describe the self-serve page contract and answer-first behavior.
|
||||||
|
- [x] AdvisoryAI docs describe FE-first answer composition and future backend ownership.
|
||||||
|
- [x] Sprint Decisions & Risks capture the phase split and its rationale.
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-03-07 | Sprint created for answer-first self-serve search UX. | Project Manager |
|
||||||
|
| 2026-03-07 | FE-SELF-001 started: defining page-owned self-serve questions and answer-first UI slice. | Developer (FE) |
|
||||||
|
| 2026-03-07 | FE-SELF-001 through FE-SELF-003 completed: added page-owned self-serve questions/clarifiers in `search-context.registry.ts`, recent-action question composition in `ambient-context.service.ts`, and answer-first/common-question UI in `global-search.component.ts`. | Developer (FE) |
|
||||||
|
| 2026-03-07 | FE-SELF-004 completed: Angular targeted tests passed 30/30 (`ambient-context.service.spec.ts`, `global-search.component.spec.ts`); Playwright self-serve + regression suites passed 10/10 across contextual, quality, and self-serve specs. | Test Automation |
|
||||||
|
| 2026-03-07 | FE-SELF-005 completed: added `docs/modules/ui/search-self-serve-contract.md` and synced UI/AdvisoryAI docs to document FE-first answer composition and future backend grounding follow-up. | Documentation author |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Decision: phase 1 is frontend-composed on top of existing unified search payloads so the product can ship a self-serve shell immediately.
|
||||||
|
- Decision: page ownership must live in the shared search context registry, not in ad hoc component conditionals.
|
||||||
|
- Decision: every non-empty search must render a visible answer state even when the answer is only a clarification request or insufficient-evidence message.
|
||||||
|
- Decision: self-serve questions are governed by `docs/modules/ui/search-self-serve-contract.md`, while contextual chips remain governed by `docs/modules/ui/search-chip-context-contract.md`.
|
||||||
|
- Risk: FE-composed answers may overstate confidence if they are not visibly tied back to retrieved evidence.
|
||||||
|
- Mitigation: show explicit evidence counts, source references, and fallback states instead of pretending every query has a confident answer.
|
||||||
|
- Risk: adding more content to the search surface can overload the header dropdown.
|
||||||
|
- Mitigation: keep question counts bounded, collapse to short cards/chips, and bias toward operator actions instead of long prose.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- 2026-03-07: FE self-serve contract + answer-first UI slice implemented with tests.
|
||||||
|
- 2026-03-10: Backend sprint `SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md` to freeze `contextAnswer` payload shape.
|
||||||
|
- 2026-03-12: Roll the contract out to additional pages via `SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md`.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Sprint 20260307-005 - AdvisoryAI Grounded Search Answer Orchestration
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Move self-serve search from frontend-composed answers to backend-grounded contextual answers with explicit citations, fallback reasons, and follow-up questions.
|
||||||
|
- Establish a unified answer contract that search and AdvisoryAI can both consume.
|
||||||
|
- Add telemetry for unanswered and reformulated journeys so self-serve gaps become measurable backlog items instead of anecdotal feedback.
|
||||||
|
- Working directory: `src/AdvisoryAI`.
|
||||||
|
- Expected evidence: targeted integration tests against the AdvisoryAI test project, updated API/docs, and execution-log entries with command evidence.
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on `docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md` for the FE shell and contract expectations.
|
||||||
|
- Safe parallelism:
|
||||||
|
- FE rollout work can continue after the answer payload shape is frozen.
|
||||||
|
- Backend ranking and telemetry work can proceed independently of additional page-contract authoring in Web.
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `docs/modules/advisory-ai/knowledge-search.md`
|
||||||
|
- `docs/modules/advisory-ai/unified-search-architecture.md`
|
||||||
|
- `docs/modules/platform/architecture-overview.md`
|
||||||
|
- `docs/code-of-conduct/TESTING_PRACTICES.md`
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
### AI-SELF-001 - Unified contextual answer payload
|
||||||
|
Status: TODO
|
||||||
|
Dependency: none
|
||||||
|
Owners: Developer (AdvisoryAI)
|
||||||
|
Task description:
|
||||||
|
- Extend unified search responses with contextual answer fields such as answer status, summary, citations, answer reason, and follow-up questions.
|
||||||
|
- Preserve backward compatibility so older Web clients can ignore the new fields safely.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Unified search response contract includes contextual answer and fallback metadata.
|
||||||
|
- [ ] Existing callers remain compatible when new fields are absent.
|
||||||
|
- [ ] API docs describe the new answer payload.
|
||||||
|
|
||||||
|
### AI-SELF-002 - Grounding and fallback policy
|
||||||
|
Status: TODO
|
||||||
|
Dependency: AI-SELF-001
|
||||||
|
Owners: Developer (AdvisoryAI), Product Manager
|
||||||
|
Task description:
|
||||||
|
- Define deterministic rules for when the service may emit:
|
||||||
|
- grounded answer
|
||||||
|
- clarification needed
|
||||||
|
- insufficient evidence
|
||||||
|
- Make the reasoning explicit in payload fields so UI does not have to guess why a state was chosen.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Grounding thresholds are explicit and deterministic.
|
||||||
|
- [ ] Clarification and insufficient-evidence reasons are returned in the payload.
|
||||||
|
- [ ] Synthesis cannot silently masquerade as a grounded answer without sufficient evidence.
|
||||||
|
|
||||||
|
### AI-SELF-003 - Follow-up question and clarification generation
|
||||||
|
Status: TODO
|
||||||
|
Dependency: AI-SELF-001
|
||||||
|
Owners: Developer (AdvisoryAI)
|
||||||
|
Task description:
|
||||||
|
- Generate contextual follow-up questions from query intent, page/domain hints, recent actions, and top evidence.
|
||||||
|
- Prefer actionable operator questions over generic reformulations.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Payload includes bounded follow-up question arrays.
|
||||||
|
- [ ] Generation uses page/domain context and query intent deterministically.
|
||||||
|
- [ ] Ambiguous queries return clarifying prompts instead of a blank answer slot.
|
||||||
|
|
||||||
|
### AI-SELF-004 - Self-serve telemetry and gap surfacing
|
||||||
|
Status: TODO
|
||||||
|
Dependency: AI-SELF-002
|
||||||
|
Owners: Developer (AdvisoryAI), Test Automation
|
||||||
|
Task description:
|
||||||
|
- Track unanswered searches, repeated reformulations, rescue-action usage, and abandonment after fallback states.
|
||||||
|
- Expose enough structured data to drive a gap-closure backlog.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Telemetry captures unanswered and reformulated journeys without persisting raw sensitive prompts unnecessarily.
|
||||||
|
- [ ] Operational docs explain how to review self-serve gaps.
|
||||||
|
- [ ] Tests cover telemetry emission for fallback paths.
|
||||||
|
|
||||||
|
### AI-SELF-005 - Targeted behavioral verification
|
||||||
|
Status: TODO
|
||||||
|
Dependency: AI-SELF-003
|
||||||
|
Owners: Test Automation, QA
|
||||||
|
Task description:
|
||||||
|
- Add focused integration tests to the AdvisoryAI test project that exercise grounded, clarify, and insufficient-evidence answer states through the real endpoint contract.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Tests target the specific AdvisoryAI `.csproj`, not a solution filter.
|
||||||
|
- [ ] Assertions verify actual answer-state payload content, not only success status codes.
|
||||||
|
- [ ] Execution log records exact commands and outcomes.
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-03-07 | Sprint created to formalize backend-grounded contextual answers after the FE answer-first shell. | Project Manager |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Decision: the backend contract must return explicit answer states instead of leaving the UI to infer confidence from cards alone.
|
||||||
|
- Decision: the product requirement is 100% response framing, not 100% hallucinated AI answers.
|
||||||
|
- Risk: answer payload inflation could make the endpoint harder to evolve if fields are not clearly optional.
|
||||||
|
- Mitigation: use additive optional fields with strict docs and integration coverage.
|
||||||
|
- Risk: telemetry may leak raw user intent if not hashed or summarized carefully.
|
||||||
|
- Mitigation: follow existing hashed-query analytics patterns and avoid raw prompt persistence where not required for history UX.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- 2026-03-10: Freeze answer payload shape and fallback taxonomy.
|
||||||
|
- 2026-03-12: Complete endpoint integration tests for answer states.
|
||||||
|
- 2026-03-13: Hand off payload contract to FE rollout sprint.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Sprint 20260307-006 - FE Self-Serve Rollout and Gap Closure
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Roll the self-serve search contract across priority pages so the experience is consistent wherever operators land.
|
||||||
|
- Close the gap between search, AdvisoryAI, and page workflows by wiring guided actions and telemetry-informed improvements.
|
||||||
|
- Convert unanswered journeys into visible UX backlog items and stronger end-to-end coverage.
|
||||||
|
- Working directory: `src/Web/StellaOps.Web`.
|
||||||
|
- Expected evidence: page-contract rollouts, Playwright operator-journey suites, updated docs/task boards, and execution-log entries.
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on:
|
||||||
|
- `docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md`
|
||||||
|
- `docs/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md`
|
||||||
|
- Safe parallelism:
|
||||||
|
- Page-by-page contract authoring can run in parallel once the shared answer panel contract is frozen.
|
||||||
|
- Telemetry review can happen in parallel with guided-action polish.
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `docs/modules/ui/architecture.md`
|
||||||
|
- `docs/modules/ui/search-chip-context-contract.md`
|
||||||
|
- `docs/modules/advisory-ai/knowledge-search.md`
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
### FE-ROLL-001 - Priority page contract rollout
|
||||||
|
Status: TODO
|
||||||
|
Dependency: none
|
||||||
|
Owners: Developer (FE), UX
|
||||||
|
Task description:
|
||||||
|
- Apply the self-serve question contract to priority routes:
|
||||||
|
- findings / triage
|
||||||
|
- policy
|
||||||
|
- doctor / system health
|
||||||
|
- timeline / audit
|
||||||
|
- releases / mission control
|
||||||
|
- Ensure every priority page exposes context rail copy, common questions, and clarifying prompts.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Each priority page has explicit self-serve question definitions.
|
||||||
|
- [ ] Empty-state and answer-state UX remain coherent across the priority routes.
|
||||||
|
- [ ] Page ownership is documented.
|
||||||
|
|
||||||
|
### FE-ROLL-002 - Guided action handoffs
|
||||||
|
Status: TODO
|
||||||
|
Dependency: FE-ROLL-001
|
||||||
|
Owners: Developer (FE), UX
|
||||||
|
Task description:
|
||||||
|
- Tighten handoffs from search answers into chat, navigation, and next-search flows.
|
||||||
|
- Reduce dead ends by always pairing answers with a credible next action.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Grounded answers expose at least one next-search or Ask-AdvisoryAI path.
|
||||||
|
- [ ] Clarification and fallback states expose page-relevant recovery actions.
|
||||||
|
- [ ] Handoffs preserve mode and context metadata.
|
||||||
|
|
||||||
|
### FE-ROLL-003 - Telemetry-driven gap review UX
|
||||||
|
Status: TODO
|
||||||
|
Dependency: FE-ROLL-001
|
||||||
|
Owners: Developer (FE), Project Manager
|
||||||
|
Task description:
|
||||||
|
- Add operator-visible and team-visible hooks for low-coverage journeys, including unanswered or repeatedly reformulated searches.
|
||||||
|
- Use telemetry to tune page questions and rescue patterns instead of relying on anecdotal adjustments.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] FE surfaces can emit telemetry markers for self-serve gap analysis.
|
||||||
|
- [ ] Docs explain how unanswered journeys feed backlog prioritization.
|
||||||
|
- [ ] At least one UX adjustment is traceable to telemetry evidence.
|
||||||
|
|
||||||
|
### FE-ROLL-004 - Full operator-journey Playwright coverage
|
||||||
|
Status: TODO
|
||||||
|
Dependency: FE-ROLL-002
|
||||||
|
Owners: Test Automation, QA
|
||||||
|
Task description:
|
||||||
|
- Add end-to-end Playwright suites that verify realistic self-serve journeys from landing on a page through answer, follow-up, and action handoff.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Playwright covers the priority page journeys end to end.
|
||||||
|
- [ ] Tests verify grounded, clarify, and recovery paths.
|
||||||
|
- [ ] Suites remain deterministic with route mocks and local fixtures only.
|
||||||
|
|
||||||
|
### FE-ROLL-005 - Docs and rollout readiness
|
||||||
|
Status: TODO
|
||||||
|
Dependency: FE-ROLL-001
|
||||||
|
Owners: Documentation author, Project Manager
|
||||||
|
Task description:
|
||||||
|
- Update UI architecture and operational guidance so teams know how to adopt and test the self-serve contract.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [ ] Docs capture rollout order and page-ownership rules.
|
||||||
|
- [ ] Sprint links and task boards reflect the rollout state.
|
||||||
|
- [ ] Risks and mitigations are documented.
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-03-07 | Sprint created to roll out the self-serve search contract after the answer-first shell and backend answer contract are defined. | Project Manager |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Decision: rollout should prioritize high-frequency operator pages before broad route coverage.
|
||||||
|
- Decision: page teams own their self-serve question sets; platform code owns composition and safety rails.
|
||||||
|
- Risk: inconsistent page adoption would make self-serve feel random across the product.
|
||||||
|
- Mitigation: maintain a priority rollout list and explicit page-ownership rules.
|
||||||
|
- Risk: adding guided actions without verification can create shallow or broken handoffs.
|
||||||
|
- Mitigation: require Playwright coverage for each high-value journey.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- 2026-03-12: Priority page rollout started after answer contract freeze.
|
||||||
|
- 2026-03-14: Guided handoff journeys verified in Playwright.
|
||||||
|
- 2026-03-16: Gap telemetry reviewed for next backlog cut.
|
||||||
@@ -172,6 +172,8 @@ Global search now consumes AKS and supports:
|
|||||||
- Doctor: `Run` (navigate to doctor and copy run command).
|
- Doctor: `Run` (navigate to doctor and copy run command).
|
||||||
- `More` action for "show more like this" local query expansion.
|
- `More` action for "show more like this" local query expansion.
|
||||||
- A shared mode switch (`Find`, `Explain`, `Act`) across search and AdvisoryAI with mode-aware chip ranking and handoff prompts.
|
- A shared mode switch (`Find`, `Explain`, `Act`) across search and AdvisoryAI with mode-aware chip ranking and handoff prompts.
|
||||||
|
- An answer-first FE shell: every non-empty search renders a visible answer state (`grounded`, `clarify`, `insufficient`) before raw cards, using existing synthesis/cards plus page context until a backend `contextAnswer` payload is introduced.
|
||||||
|
- Page-owned self-serve questions and clarifiers, defined in `docs/modules/ui/search-self-serve-contract.md`, so search can offer "Common questions" and recovery prompts without per-page conditionals in the component.
|
||||||
- Zero-result rescue actions that keep the current query visible while broadening scope, trying a related pivot, retrying with page context, or opening AdvisoryAI reformulation.
|
- Zero-result rescue actions that keep the current query visible while broadening scope, trying a related pivot, retrying with page context, or opening AdvisoryAI reformulation.
|
||||||
- AdvisoryAI evidence-first next-step cards that can return search pivots (`chat_next_step_search`, `chat_next_step_policy`) back into global search or open cited evidence/context directly.
|
- AdvisoryAI evidence-first next-step cards that can return search pivots (`chat_next_step_search`, `chat_next_step_policy`) back into global search or open cited evidence/context directly.
|
||||||
- Search-quality metrics taxonomy is standardized on `query`, `click`, and `zero_result` event types (no legacy `search` event dependency in quality SQL).
|
- Search-quality metrics taxonomy is standardized on `query`, `click`, and `zero_result` event types (no legacy `search` event dependency in quality SQL).
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
- `docs/implplan/SPRINT_20260221_041_FE_prealpha_ia_ops_setup_rewire.md`
|
- `docs/implplan/SPRINT_20260221_041_FE_prealpha_ia_ops_setup_rewire.md`
|
||||||
- `docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md`
|
- `docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md`
|
||||||
- `docs/implplan/SPRINT_20260306_002_FE_search_advisory_quality_ux.md`
|
- `docs/implplan/SPRINT_20260306_002_FE_search_advisory_quality_ux.md`
|
||||||
|
- `docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md`
|
||||||
|
- `docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md`
|
||||||
|
|
||||||
## Delivery Tasks
|
## Delivery Tasks
|
||||||
- [DONE] 041-T1 Root IA/nav rewrite (Mission Control + Ops + Setup)
|
- [DONE] 041-T1 Root IA/nav rewrite (Mission Control + Ops + Setup)
|
||||||
@@ -28,6 +30,16 @@
|
|||||||
- [DONE] FE-UX-003 Zero-result rescue and reformulation UX
|
- [DONE] FE-UX-003 Zero-result rescue and reformulation UX
|
||||||
- [DONE] FE-UX-004 AdvisoryAI evidence-first next-step cards
|
- [DONE] FE-UX-004 AdvisoryAI evidence-first next-step cards
|
||||||
- [DONE] FE-UX-005 Docs sync and rollout notes for search/advisory quality UX
|
- [DONE] FE-UX-005 Docs sync and rollout notes for search/advisory quality UX
|
||||||
|
- [DONE] FE-SELF-001 Page-owned self-serve search contract
|
||||||
|
- [DONE] FE-SELF-002 Answer-first search panel
|
||||||
|
- [DONE] FE-SELF-003 Common questions and follow-up questions
|
||||||
|
- [DONE] FE-SELF-004 Self-serve verification and behavior hardening
|
||||||
|
- [DONE] FE-SELF-005 Docs sync and rollout guidance for self-serve search
|
||||||
|
- [TODO] FE-ROLL-001 Priority page self-serve rollout
|
||||||
|
- [TODO] FE-ROLL-002 Guided action handoffs
|
||||||
|
- [TODO] FE-ROLL-003 Telemetry-driven self-serve gap review UX
|
||||||
|
- [TODO] FE-ROLL-004 Full operator-journey Playwright coverage
|
||||||
|
- [TODO] FE-ROLL-005 Docs and rollout readiness
|
||||||
- [DONE] WEB-CTX-E2E Playwright coverage for contextual suggestions + ambient last-action payload
|
- [DONE] WEB-CTX-E2E Playwright coverage for contextual suggestions + ambient last-action payload
|
||||||
- [DONE] FE-UX-E2E Playwright coverage for mode switching, rescue flows, and AdvisoryAI next-step cards
|
- [DONE] FE-UX-E2E Playwright coverage for mode switching, rescue flows, and AdvisoryAI next-step cards
|
||||||
- [DONE] WEB-CTX-NONOBVIOUS Strategic non-obvious suggestion recipes (cross-domain + action-aware)
|
- [DONE] WEB-CTX-NONOBVIOUS Strategic non-obvious suggestion recipes (cross-domain + action-aware)
|
||||||
|
|||||||
@@ -199,11 +199,13 @@ Each feature folder builds as a **standalone route** (lazy loaded). All HTTP sha
|
|||||||
* **Strategic non-obvious suggestions**: each page scope injects at least one cross-domain guidance query (for example, findings -> policy/VEX impact, policy -> impacted findings, doctor -> release blockers) and switches to action-aware variants after meaningful user actions.
|
* **Strategic non-obvious suggestions**: each page scope injects at least one cross-domain guidance query (for example, findings -> policy/VEX impact, policy -> impacted findings, doctor -> release blockers) and switches to action-aware variants after meaningful user actions.
|
||||||
* **Explainable chips**: suggestion queries remain short and executable, while a separate rationale line explains whether a chip comes from page defaults, recent actions, or strategic cross-domain guidance.
|
* **Explainable chips**: suggestion queries remain short and executable, while a separate rationale line explains whether a chip comes from page defaults, recent actions, or strategic cross-domain guidance.
|
||||||
* **Shared operator modes**: global search and AdvisoryAI share one persisted mode state (`Find`, `Explain`, `Act`) via `SearchExperienceModeService`; the mode changes chip ordering, Ask-AI prompts, empty-state starters, and structured next-step behavior.
|
* **Shared operator modes**: global search and AdvisoryAI share one persisted mode state (`Find`, `Explain`, `Act`) via `SearchExperienceModeService`; the mode changes chip ordering, Ask-AI prompts, empty-state starters, and structured next-step behavior.
|
||||||
|
* **Answer-first search**: every non-empty search renders a visible answer panel before raw cards; the panel must resolve to `grounded`, `clarify`, or `insufficient` and never leave the operator with an unexplained blank result area.
|
||||||
|
* **Page-owned self-serve questions**: priority pages define common questions and clarifying prompts in the shared search context registry; empty-state search uses those as "Common questions" and answer states reuse them as follow-up or clarification buttons.
|
||||||
* **Zero-result rescue loop**: no-result states must expose recovery actions for broadening scope, trying a related pivot, retrying with page context, and opening AdvisoryAI reformulation with the active mode and query context preserved.
|
* **Zero-result rescue loop**: no-result states must expose recovery actions for broadening scope, trying a related pivot, retrying with page context, and opening AdvisoryAI reformulation with the active mode and query context preserved.
|
||||||
* **Search-surface focus rule**: focus movement into controls inside the global-search surface (mode buttons, scope toggle, rescue buttons, filters) must not collapse the panel; the surface behaves like one command workspace rather than a disposable tooltip.
|
* **Search-surface focus rule**: focus movement into controls inside the global-search surface (mode buttons, scope toggle, rescue buttons, filters) must not collapse the panel; the surface behaves like one command workspace rather than a disposable tooltip.
|
||||||
* **AdvisoryAI next-step cards**: assistant responses with citations render structured cards for evidence inspection, context navigation, deeper search, and policy pivots; search-return actions must emit deterministic `chat_next_step_*` metadata back into global search.
|
* **AdvisoryAI next-step cards**: assistant responses with citations render structured cards for evidence inspection, context navigation, deeper search, and policy pivots; search-return actions must emit deterministic `chat_next_step_*` metadata back into global search.
|
||||||
* **Ambient payload activation**: each global search request sends ambient context (`currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, optional `lastAction`) so AdvisoryAI can apply contextual ranking/refinement.
|
* **Ambient payload activation**: each global search request sends ambient context (`currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, optional `lastAction`) so AdvisoryAI can apply contextual ranking/refinement.
|
||||||
* **Chip contract governance**: page-owned chip arrays and route mappings are defined by `docs/modules/ui/search-chip-context-contract.md` and implemented in `search-context.registry.ts`.
|
* **Contract governance**: page-owned chip arrays follow `docs/modules/ui/search-chip-context-contract.md`, while answer-first self-serve questions and fallback states follow `docs/modules/ui/search-self-serve-contract.md`; both are implemented in `search-context.registry.ts`.
|
||||||
* **Fallback transparency**: when unified search drops to legacy fallback, global search displays an explicit degraded banner and emits enter/exit telemetry markers for operator visibility.
|
* **Fallback transparency**: when unified search drops to legacy fallback, global search displays an explicit degraded banner and emits enter/exit telemetry markers for operator visibility.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
|||||||
- Update this file when new scoped work is approved.
|
- Update this file when new scoped work is approved.
|
||||||
|
|
||||||
## Near-term deliverables
|
## Near-term deliverables
|
||||||
- TBD (add when sprint is staffed).
|
- `SPRINT_20260307_004_FE_self_serve_search_answer_first.md` - answer-first search shell, page-owned self-serve questions, and explicit fallback states.
|
||||||
|
- `SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md` - page rollout, guided handoffs, and telemetry-driven gap closure.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- `docs/modules/ui/architecture.md`
|
- `docs/modules/ui/architecture.md`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
- Define one deterministic contract for page-aware search chips.
|
- Define one deterministic contract for page-aware search chips.
|
||||||
- Let feature teams add page suggestions without editing `AmbientContextService` logic.
|
- Let feature teams add page suggestions without editing `AmbientContextService` logic.
|
||||||
- Blend route context, last few page actions, and bounded suggestion randomization.
|
- Blend route context, last few page actions, and bounded suggestion randomization.
|
||||||
|
- Answer-first self-serve questions and fallback states are governed separately in `docs/modules/ui/search-self-serve-contract.md`.
|
||||||
|
|
||||||
## Rule (mandatory for page teams)
|
## Rule (mandatory for page teams)
|
||||||
- Every page that needs custom search chips must declare a context entry in `SEARCH_CONTEXT_DEFINITIONS`.
|
- Every page that needs custom search chips must declare a context entry in `SEARCH_CONTEXT_DEFINITIONS`.
|
||||||
|
|||||||
65
docs/modules/ui/search-self-serve-contract.md
Normal file
65
docs/modules/ui/search-self-serve-contract.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Search Self-Serve Contract
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
- Define the page-owned contract for answer-first search.
|
||||||
|
- Ensure every high-value page can expose common operator questions and clarifying prompts without editing `GlobalSearchComponent`.
|
||||||
|
- Keep the self-serve experience deterministic, evidence-first, and testable.
|
||||||
|
|
||||||
|
## Rule (mandatory for page teams)
|
||||||
|
- Every page that wants answer-first self-serve behavior must declare `selfServe` metadata in `SEARCH_CONTEXT_DEFINITIONS`.
|
||||||
|
- Page components should continue to implement `SearchContextComponent` so ownership remains explicit.
|
||||||
|
- `selfServe` definitions must provide one or both of:
|
||||||
|
- `commonQuestions[]`
|
||||||
|
- `clarifyingQuestions[]`
|
||||||
|
- Question entries must provide:
|
||||||
|
- `key` / `fallback` for the executable question text
|
||||||
|
- optional `kind` (`page`, `clarify`, `recent`)
|
||||||
|
- optional `preferredModes` (`find`, `explain`, `act`)
|
||||||
|
- Question arrays must stay deterministic and bounded:
|
||||||
|
- at most 3 base common questions per page
|
||||||
|
- at most 2 base clarifying questions per page
|
||||||
|
- concise, operator-facing wording
|
||||||
|
- no tenant secrets or volatile IDs in fallback copy
|
||||||
|
|
||||||
|
## Answer-first UX contract
|
||||||
|
- Every non-empty search must render one visible answer state before raw results:
|
||||||
|
- `grounded`
|
||||||
|
- `clarify`
|
||||||
|
- `insufficient`
|
||||||
|
- `grounded` states must expose visible evidence summary plus citations or top-result references.
|
||||||
|
- `clarify` states must offer narrowing questions instead of a blank panel.
|
||||||
|
- `insufficient` states must explain the lack of grounding and still provide credible next questions or next searches.
|
||||||
|
|
||||||
|
## Runtime behavior
|
||||||
|
- Empty-state search uses `commonQuestions[]` for page-owned "Common questions".
|
||||||
|
- The active mode (`Find`, `Explain`, `Act`) reorders questions via `preferredModes`.
|
||||||
|
- A recent-action question may be prepended from the latest meaningful page action:
|
||||||
|
- `Find` -> related discovery question
|
||||||
|
- `Explain` -> why-it-matters question
|
||||||
|
- `Act` -> what-to-do-next question
|
||||||
|
- Result-state answer panels use:
|
||||||
|
- `commonQuestions[]` as follow-up questions when grounded evidence exists
|
||||||
|
- `clarifyingQuestions[]` when no grounded answer exists
|
||||||
|
- "Search next" remains driven by contextual chip logic so pages do not need to define a second parallel action system.
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
- Page registry and interfaces:
|
||||||
|
- `src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts`
|
||||||
|
- Runtime composition:
|
||||||
|
- `src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts`
|
||||||
|
- Search surface:
|
||||||
|
- `src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts`
|
||||||
|
|
||||||
|
## Testing requirements
|
||||||
|
1. Add unit coverage for route-specific questions in `ambient-context.service.spec.ts`.
|
||||||
|
2. Add unit coverage for answer-state rendering in `global-search.component.spec.ts`.
|
||||||
|
3. Add Playwright coverage for:
|
||||||
|
- grounded answer
|
||||||
|
- clarify recovery
|
||||||
|
- answer-to-AdvisoryAI handoff
|
||||||
|
4. Keep route and API behavior mocked/deterministic; no live network dependencies.
|
||||||
|
|
||||||
|
## Relationship to chip contract
|
||||||
|
- `search-chip-context-contract.md` still governs contextual chips and route-suggestion behavior.
|
||||||
|
- This document governs answer-first questions and fallback states.
|
||||||
|
- Page teams adding self-serve behavior usually need to update both contracts together.
|
||||||
@@ -9,14 +9,19 @@ import type {
|
|||||||
UnifiedSearchFilter,
|
UnifiedSearchFilter,
|
||||||
} from '../api/unified-search.models';
|
} from '../api/unified-search.models';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_CLARIFYING_QUESTIONS,
|
||||||
|
DEFAULT_COMMON_QUESTIONS,
|
||||||
DEFAULT_CHAT_SUGGESTIONS,
|
DEFAULT_CHAT_SUGGESTIONS,
|
||||||
DEFAULT_SEARCH_SUGGESTIONS,
|
DEFAULT_SEARCH_SUGGESTIONS,
|
||||||
SEARCH_CONTEXT_DEFINITIONS,
|
SEARCH_CONTEXT_DEFINITIONS,
|
||||||
type SearchContextDefinition,
|
type SearchContextDefinition,
|
||||||
|
type SearchQuestionChip,
|
||||||
type SearchSuggestionChip,
|
type SearchSuggestionChip,
|
||||||
} from './search-context.registry';
|
} from './search-context.registry';
|
||||||
|
import type { SearchExperienceMode } from './search-experience-mode.service';
|
||||||
|
|
||||||
export type ContextSuggestion = SearchSuggestionChip;
|
export type ContextSuggestion = SearchSuggestionChip;
|
||||||
|
export type ContextQuestion = SearchQuestionChip;
|
||||||
|
|
||||||
export interface SearchContextPanelToken {
|
export interface SearchContextPanelToken {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -114,7 +119,7 @@ export class AmbientContextService {
|
|||||||
const recentActions = this.getActiveActions(scopeKey);
|
const recentActions = this.getActiveActions(scopeKey);
|
||||||
const actionSuggestions = this.buildRecentActionSuggestions(recentActions, 2);
|
const actionSuggestions = this.buildRecentActionSuggestions(recentActions, 2);
|
||||||
const strategicSuggestion = this.buildStrategicSuggestion(scope, recentActions);
|
const strategicSuggestion = this.buildStrategicSuggestion(scope, recentActions);
|
||||||
const rotatedRouteSuggestions = this.rotateSuggestions(routeSuggestions, `${scope}|${scopeKey}`);
|
const rotatedRouteSuggestions = this.rotateEntries(routeSuggestions, `${scope}|${scopeKey}`);
|
||||||
|
|
||||||
const deduped = [...actionSuggestions, strategicSuggestion, ...rotatedRouteSuggestions]
|
const deduped = [...actionSuggestions, strategicSuggestion, ...rotatedRouteSuggestions]
|
||||||
.filter((entry): entry is ContextSuggestion => entry !== null)
|
.filter((entry): entry is ContextSuggestion => entry !== null)
|
||||||
@@ -125,6 +130,39 @@ export class AmbientContextService {
|
|||||||
return deduped.slice(0, 4);
|
return deduped.slice(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCommonQuestions(mode: SearchExperienceMode): readonly ContextQuestion[] {
|
||||||
|
const route = this.routeUrl();
|
||||||
|
const scopeKey = this.routeScope(route);
|
||||||
|
const context = this.findContext(route, (candidate) =>
|
||||||
|
Array.isArray(candidate.selfServe?.commonQuestions)
|
||||||
|
&& candidate.selfServe!.commonQuestions!.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseQuestions = context?.selfServe?.commonQuestions ?? DEFAULT_COMMON_QUESTIONS;
|
||||||
|
const recentActions = this.getActiveActions(scopeKey);
|
||||||
|
const recentQuestion = this.buildRecentActionQuestion(recentActions[0] ?? null, mode);
|
||||||
|
const rotatedQuestions = this.rotateEntries(baseQuestions, `${scopeKey}|common|${mode}`);
|
||||||
|
|
||||||
|
return [recentQuestion, ...rotatedQuestions]
|
||||||
|
.filter((entry): entry is ContextQuestion => entry !== null)
|
||||||
|
.filter((entry, index, list) =>
|
||||||
|
list.findIndex((candidate) => candidate.fallback.toLowerCase() === entry.fallback.toLowerCase()) === index,
|
||||||
|
)
|
||||||
|
.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
getClarifyingQuestions(mode: SearchExperienceMode): readonly ContextQuestion[] {
|
||||||
|
const route = this.routeUrl();
|
||||||
|
const scopeKey = this.routeScope(route);
|
||||||
|
const context = this.findContext(route, (candidate) =>
|
||||||
|
Array.isArray(candidate.selfServe?.clarifyingQuestions)
|
||||||
|
&& candidate.selfServe!.clarifyingQuestions!.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseQuestions = context?.selfServe?.clarifyingQuestions ?? DEFAULT_CLARIFYING_QUESTIONS;
|
||||||
|
return this.rotateEntries(baseQuestions, `${scopeKey}|clarify|${mode}`).slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
getChatSuggestions(): readonly ContextSuggestion[] {
|
getChatSuggestions(): readonly ContextSuggestion[] {
|
||||||
const route = this.routeUrl();
|
const route = this.routeUrl();
|
||||||
const context = this.findContext(route, (candidate) => {
|
const context = this.findContext(route, (candidate) => {
|
||||||
@@ -375,6 +413,34 @@ export class AmbientContextService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildRecentActionQuestion(
|
||||||
|
action: UnifiedSearchAmbientAction | null,
|
||||||
|
mode: SearchExperienceMode,
|
||||||
|
): ContextQuestion | null {
|
||||||
|
if (!action) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint = this.buildActionHint(action);
|
||||||
|
if (!hint) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback = `What else is related to ${hint}?`;
|
||||||
|
if (mode === 'explain') {
|
||||||
|
fallback = `Why does ${hint} matter on this page?`;
|
||||||
|
} else if (mode === 'act') {
|
||||||
|
fallback = `What should I do next for ${hint}?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'ui.search.question.recent_action.default',
|
||||||
|
fallback,
|
||||||
|
kind: 'recent',
|
||||||
|
preferredModes: [mode],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private describeAction(action: UnifiedSearchAmbientAction): string | null {
|
private describeAction(action: UnifiedSearchAmbientAction): string | null {
|
||||||
const hint = this.buildActionHint(action);
|
const hint = this.buildActionHint(action);
|
||||||
const normalizedAction = action.action.trim().toLowerCase();
|
const normalizedAction = action.action.trim().toLowerCase();
|
||||||
@@ -511,10 +577,10 @@ export class AmbientContextService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private rotateSuggestions(
|
private rotateEntries<T extends { fallback: string }>(
|
||||||
suggestions: readonly ContextSuggestion[],
|
suggestions: readonly T[],
|
||||||
scope: string,
|
scope: string,
|
||||||
): readonly ContextSuggestion[] {
|
): readonly T[] {
|
||||||
if (suggestions.length <= 1) {
|
if (suggestions.length <= 1) {
|
||||||
return suggestions;
|
return suggestions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { UnifiedSearchDomain } from '../api/unified-search.models';
|
|||||||
import type { SearchExperienceMode } from './search-experience-mode.service';
|
import type { SearchExperienceMode } from './search-experience-mode.service';
|
||||||
|
|
||||||
export type SearchSuggestionKind = 'page' | 'recent' | 'strategy';
|
export type SearchSuggestionKind = 'page' | 'recent' | 'strategy';
|
||||||
|
export type SearchQuestionKind = 'page' | 'clarify' | 'recent';
|
||||||
|
|
||||||
export interface SearchSuggestionChip {
|
export interface SearchSuggestionChip {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -12,6 +13,18 @@ export interface SearchSuggestionChip {
|
|||||||
preferredModes?: readonly SearchExperienceMode[];
|
preferredModes?: readonly SearchExperienceMode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchQuestionChip {
|
||||||
|
key: string;
|
||||||
|
fallback: string;
|
||||||
|
kind?: SearchQuestionKind;
|
||||||
|
preferredModes?: readonly SearchExperienceMode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchSelfServeDefinition {
|
||||||
|
commonQuestions?: readonly SearchQuestionChip[];
|
||||||
|
clarifyingQuestions?: readonly SearchQuestionChip[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchContextPresentation {
|
export interface SearchContextPresentation {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
titleFallback: string;
|
titleFallback: string;
|
||||||
@@ -26,6 +39,7 @@ export interface SearchContextDefinition {
|
|||||||
domain?: UnifiedSearchDomain;
|
domain?: UnifiedSearchDomain;
|
||||||
searchSuggestions?: readonly SearchSuggestionChip[];
|
searchSuggestions?: readonly SearchSuggestionChip[];
|
||||||
chatSuggestions?: readonly SearchSuggestionChip[];
|
chatSuggestions?: readonly SearchSuggestionChip[];
|
||||||
|
selfServe?: SearchSelfServeDefinition;
|
||||||
chatRoutePattern?: RegExp;
|
chatRoutePattern?: RegExp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +65,16 @@ function withReason(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withQuestionKind(
|
||||||
|
questions: readonly SearchQuestionChip[],
|
||||||
|
kind: SearchQuestionKind,
|
||||||
|
): readonly SearchQuestionChip[] {
|
||||||
|
return questions.map((question) => ({
|
||||||
|
...question,
|
||||||
|
kind: question.kind ?? kind,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
|
export const DEFAULT_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
|
||||||
{ key: 'ui.search.suggestion.default.deploy', fallback: 'How do I deploy?', preferredModes: ['act'] },
|
{ key: 'ui.search.suggestion.default.deploy', fallback: 'How do I deploy?', preferredModes: ['act'] },
|
||||||
{ key: 'ui.search.suggestion.default.vex', fallback: 'What is a VEX statement?', preferredModes: ['explain'] },
|
{ key: 'ui.search.suggestion.default.vex', fallback: 'What is a VEX statement?', preferredModes: ['explain'] },
|
||||||
@@ -64,6 +88,25 @@ export const DEFAULT_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [
|
|||||||
{ key: 'ui.chat.suggestion.default.health_checks', fallback: 'What health checks should I run first?' },
|
{ key: 'ui.chat.suggestion.default.health_checks', fallback: 'What health checks should I run first?' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_COMMON_QUESTIONS: readonly SearchQuestionChip[] = withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.default.changed', fallback: 'What changed most recently?', preferredModes: ['find', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.default.evidence', fallback: 'Show me the strongest evidence', preferredModes: ['explain'] },
|
||||||
|
{ key: 'ui.search.question.default.next_step', fallback: 'What should I inspect next?', preferredModes: ['act'] },
|
||||||
|
], 'page');
|
||||||
|
|
||||||
|
export const DEFAULT_CLARIFYING_QUESTIONS: readonly SearchQuestionChip[] = withQuestionKind([
|
||||||
|
{
|
||||||
|
key: 'ui.search.question.default.clarify_target',
|
||||||
|
fallback: 'Which workload, release, or policy should I narrow this to?',
|
||||||
|
preferredModes: ['find', 'explain'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ui.search.question.default.clarify_scope',
|
||||||
|
fallback: 'Should I stay on this page or broaden to all domains?',
|
||||||
|
preferredModes: ['act'],
|
||||||
|
},
|
||||||
|
], 'clarify');
|
||||||
|
|
||||||
const FINDINGS_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
|
const FINDINGS_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = withReason([
|
||||||
{ key: 'ui.search.suggestion.findings.critical', fallback: 'critical findings', preferredModes: ['find'] },
|
{ key: 'ui.search.suggestion.findings.critical', fallback: 'critical findings', preferredModes: ['find'] },
|
||||||
{ key: 'ui.search.suggestion.findings.reachable', fallback: 'reachable vulnerabilities', preferredModes: ['find', 'explain'] },
|
{ key: 'ui.search.suggestion.findings.reachable', fallback: 'reachable vulnerabilities', preferredModes: ['find', 'explain'] },
|
||||||
@@ -108,6 +151,102 @@ const POLICY_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [
|
|||||||
{ key: 'ui.chat.suggestion.policy.add_exception', fallback: 'How do I add an exception?' },
|
{ key: 'ui.chat.suggestion.policy.add_exception', fallback: 'How do I add an exception?' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FINDINGS_SELF_SERVE: SearchSelfServeDefinition = {
|
||||||
|
commonQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.findings.exploitable', fallback: 'Why is this exploitable in my environment?', preferredModes: ['explain'] },
|
||||||
|
{ key: 'ui.search.question.findings.release_blocker', fallback: 'What evidence blocks this release?', preferredModes: ['act', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.findings.remediation', fallback: 'What is the safest remediation path?', preferredModes: ['act'] },
|
||||||
|
], 'page'),
|
||||||
|
clarifyingQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.findings.clarify_target', fallback: 'Which CVE, workload, or package should I narrow this to?', preferredModes: ['find', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.findings.clarify_scope', fallback: 'Should I focus on reachable, production, or unresolved findings?', preferredModes: ['find', 'act'] },
|
||||||
|
], 'clarify'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const VEX_SELF_SERVE: SearchSelfServeDefinition = {
|
||||||
|
commonQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.vex.why_not_affected', fallback: 'Why is this marked not affected?', preferredModes: ['explain'] },
|
||||||
|
{ key: 'ui.search.question.vex.covered_components', fallback: 'Which components are covered by this VEX?', preferredModes: ['find'] },
|
||||||
|
{ key: 'ui.search.question.vex.conflicting_evidence', fallback: 'What evidence conflicts with this VEX?', preferredModes: ['act', 'explain'] },
|
||||||
|
], 'page'),
|
||||||
|
clarifyingQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.vex.clarify_statement', fallback: 'Which statement, component, or product range should I narrow this to?', preferredModes: ['find', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.vex.clarify_need', fallback: 'Do you want exploitability meaning, coverage, or conflict evidence?', preferredModes: ['act'] },
|
||||||
|
], 'clarify'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLICY_SELF_SERVE: SearchSelfServeDefinition = {
|
||||||
|
commonQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.policy.why_failing', fallback: 'Why is this gate failing?', preferredModes: ['explain'] },
|
||||||
|
{ key: 'ui.search.question.policy.impacted_findings', fallback: 'What findings are impacted by this rule?', preferredModes: ['find', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.policy.exception', fallback: 'What is the safest exception path?', preferredModes: ['act'] },
|
||||||
|
], 'page'),
|
||||||
|
clarifyingQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.policy.clarify_target', fallback: 'Which rule, environment, or control should I narrow this to?', preferredModes: ['find', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.policy.clarify_need', fallback: 'Do you want recent failures, exceptions, or promotion impact?', preferredModes: ['act'] },
|
||||||
|
], 'clarify'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCTOR_SELF_SERVE: SearchSelfServeDefinition = {
|
||||||
|
commonQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.doctor.blocking_check', fallback: 'Which failing check is blocking release?', preferredModes: ['act', 'find'] },
|
||||||
|
{ key: 'ui.search.question.doctor.verify_fix', fallback: 'How do I verify the fix safely?', preferredModes: ['act'] },
|
||||||
|
{ key: 'ui.search.question.doctor.changed', fallback: 'What changed before this health issue?', preferredModes: ['explain'] },
|
||||||
|
], 'page'),
|
||||||
|
clarifyingQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.doctor.clarify_check', fallback: 'Which check or symptom should I narrow this to?', preferredModes: ['find'] },
|
||||||
|
{ key: 'ui.search.question.doctor.clarify_need', fallback: 'Do you want diagnosis, remediation, or verification steps?', preferredModes: ['act', 'explain'] },
|
||||||
|
], 'clarify'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRAPH_SELF_SERVE: SearchSelfServeDefinition = {
|
||||||
|
commonQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.graph.path', fallback: 'Which path makes this reachable?', preferredModes: ['find', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.graph.blast_radius', fallback: 'What is the blast radius of this node?', preferredModes: ['explain'] },
|
||||||
|
{ key: 'ui.search.question.graph.next_hop', fallback: 'What should I inspect next on this path?', preferredModes: ['act'] },
|
||||||
|
], 'page'),
|
||||||
|
clarifyingQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.graph.clarify_node', fallback: 'Which node, package, or edge should I narrow this to?', preferredModes: ['find'] },
|
||||||
|
{ key: 'ui.search.question.graph.clarify_need', fallback: 'Do you want reachability, impact, or next-step guidance?', preferredModes: ['act', 'explain'] },
|
||||||
|
], 'clarify'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPS_MEMORY_SELF_SERVE: SearchSelfServeDefinition = {
|
||||||
|
commonQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.ops_memory.pattern', fallback: 'Have we seen this pattern before?', preferredModes: ['find', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.ops_memory.runbook', fallback: 'What runbook usually fixes this fastest?', preferredModes: ['act'] },
|
||||||
|
{ key: 'ui.search.question.ops_memory.repeat', fallback: 'What repeated failures are related to this?', preferredModes: ['find'] },
|
||||||
|
], 'page'),
|
||||||
|
clarifyingQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.ops_memory.clarify_job', fallback: 'Which job, incident, or recurring failure should I narrow this to?', preferredModes: ['find'] },
|
||||||
|
{ key: 'ui.search.question.ops_memory.clarify_need', fallback: 'Do you want precedent, likely cause, or recommended recovery?', preferredModes: ['act', 'explain'] },
|
||||||
|
], 'clarify'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIMELINE_SELF_SERVE: SearchSelfServeDefinition = {
|
||||||
|
commonQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.timeline.before_incident', fallback: 'What changed before this incident?', preferredModes: ['explain'] },
|
||||||
|
{ key: 'ui.search.question.timeline.introduced_risk', fallback: 'Which release introduced this risk?', preferredModes: ['find', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.timeline.next_event', fallback: 'What else happened around this event?', preferredModes: ['act', 'find'] },
|
||||||
|
], 'page'),
|
||||||
|
clarifyingQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.timeline.clarify_window', fallback: 'Which deployment, incident, or time window should I narrow this to?', preferredModes: ['find'] },
|
||||||
|
{ key: 'ui.search.question.timeline.clarify_need', fallback: 'Do you want causes, impacts, or follow-up events?', preferredModes: ['act', 'explain'] },
|
||||||
|
], 'clarify'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const RELEASES_SELF_SERVE: SearchSelfServeDefinition = {
|
||||||
|
commonQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.releases.blocked', fallback: 'What blocked this promotion?', preferredModes: ['act', 'explain'] },
|
||||||
|
{ key: 'ui.search.question.releases.approvals', fallback: 'Which approvals are missing?', preferredModes: ['find'] },
|
||||||
|
{ key: 'ui.search.question.releases.next_step', fallback: 'What is the safest next step to ship?', preferredModes: ['act'] },
|
||||||
|
], 'page'),
|
||||||
|
clarifyingQuestions: withQuestionKind([
|
||||||
|
{ key: 'ui.search.question.releases.clarify_target', fallback: 'Which environment or release should I narrow this to?', preferredModes: ['find'] },
|
||||||
|
{ key: 'ui.search.question.releases.clarify_need', fallback: 'Do you want blockers, approvals, or policy impact?', preferredModes: ['act', 'explain'] },
|
||||||
|
], 'clarify'),
|
||||||
|
};
|
||||||
|
|
||||||
export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
||||||
{
|
{
|
||||||
id: 'findings',
|
id: 'findings',
|
||||||
@@ -120,6 +259,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
},
|
},
|
||||||
domain: 'findings',
|
domain: 'findings',
|
||||||
searchSuggestions: FINDINGS_SEARCH_SUGGESTIONS,
|
searchSuggestions: FINDINGS_SEARCH_SUGGESTIONS,
|
||||||
|
selfServe: FINDINGS_SELF_SERVE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'findings-chat-detail',
|
id: 'findings-chat-detail',
|
||||||
@@ -137,6 +277,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
descriptionFallback: 'Search vendor statements, affected ranges, and disposition evidence.',
|
descriptionFallback: 'Search vendor statements, affected ranges, and disposition evidence.',
|
||||||
},
|
},
|
||||||
domain: 'vex',
|
domain: 'vex',
|
||||||
|
selfServe: VEX_SELF_SERVE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policy',
|
id: 'policy',
|
||||||
@@ -150,6 +291,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
domain: 'policy',
|
domain: 'policy',
|
||||||
searchSuggestions: POLICY_SEARCH_SUGGESTIONS,
|
searchSuggestions: POLICY_SEARCH_SUGGESTIONS,
|
||||||
chatSuggestions: POLICY_CHAT_SUGGESTIONS,
|
chatSuggestions: POLICY_CHAT_SUGGESTIONS,
|
||||||
|
selfServe: POLICY_SELF_SERVE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'doctor',
|
id: 'doctor',
|
||||||
@@ -162,6 +304,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
},
|
},
|
||||||
domain: 'knowledge',
|
domain: 'knowledge',
|
||||||
searchSuggestions: DOCTOR_SEARCH_SUGGESTIONS,
|
searchSuggestions: DOCTOR_SEARCH_SUGGESTIONS,
|
||||||
|
selfServe: DOCTOR_SELF_SERVE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'graph',
|
id: 'graph',
|
||||||
@@ -173,6 +316,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
descriptionFallback: 'Follow dependency and reachability paths across the platform.',
|
descriptionFallback: 'Follow dependency and reachability paths across the platform.',
|
||||||
},
|
},
|
||||||
domain: 'graph',
|
domain: 'graph',
|
||||||
|
selfServe: GRAPH_SELF_SERVE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ops-memory',
|
id: 'ops-memory',
|
||||||
@@ -184,6 +328,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
descriptionFallback: 'Search recurring incidents, jobs, and learned operator runbooks.',
|
descriptionFallback: 'Search recurring incidents, jobs, and learned operator runbooks.',
|
||||||
},
|
},
|
||||||
domain: 'ops_memory',
|
domain: 'ops_memory',
|
||||||
|
selfServe: OPS_MEMORY_SELF_SERVE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'timeline',
|
id: 'timeline',
|
||||||
@@ -196,6 +341,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
},
|
},
|
||||||
domain: 'timeline',
|
domain: 'timeline',
|
||||||
searchSuggestions: TIMELINE_SEARCH_SUGGESTIONS,
|
searchSuggestions: TIMELINE_SEARCH_SUGGESTIONS,
|
||||||
|
selfServe: TIMELINE_SELF_SERVE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'releases',
|
id: 'releases',
|
||||||
@@ -207,5 +353,6 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
descriptionFallback: 'Investigate promotions, approvals, and blockers.',
|
descriptionFallback: 'Investigate promotions, approvals, and blockers.',
|
||||||
},
|
},
|
||||||
searchSuggestions: RELEASES_SEARCH_SUGGESTIONS,
|
searchSuggestions: RELEASES_SEARCH_SUGGESTIONS,
|
||||||
|
selfServe: RELEASES_SELF_SERVE,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ type SearchSuggestionView = {
|
|||||||
kind: 'page' | 'recent' | 'strategy';
|
kind: 'page' | 'recent' | 'strategy';
|
||||||
preferredModes?: readonly SearchExperienceMode[];
|
preferredModes?: readonly SearchExperienceMode[];
|
||||||
};
|
};
|
||||||
|
type SearchQuestionView = {
|
||||||
|
query: string;
|
||||||
|
kind: 'page' | 'clarify' | 'recent';
|
||||||
|
preferredModes?: readonly SearchExperienceMode[];
|
||||||
|
};
|
||||||
type SearchContextPanelView = {
|
type SearchContextPanelView = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -62,6 +67,20 @@ type RescueActionView = {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
type SearchAnswerView = {
|
||||||
|
status: 'grounded' | 'clarify' | 'insufficient';
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
evidence: string;
|
||||||
|
citations: Array<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
questionLabel: string;
|
||||||
|
questions: SearchQuestionView[];
|
||||||
|
nextSearches: SearchSuggestionView[];
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-global-search',
|
selector: 'app-global-search',
|
||||||
@@ -134,6 +153,77 @@ type RescueActionView = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (searchAnswer(); as answer) {
|
||||||
|
<section
|
||||||
|
class="search__answer"
|
||||||
|
[class.search__answer--clarify]="answer.status === 'clarify'"
|
||||||
|
[class.search__answer--insufficient]="answer.status === 'insufficient'"
|
||||||
|
[attr.data-answer-status]="answer.status"
|
||||||
|
>
|
||||||
|
<div class="search__answer-eyebrow">{{ answer.eyebrow }}</div>
|
||||||
|
<div class="search__answer-header">
|
||||||
|
<div>
|
||||||
|
<div class="search__answer-title">{{ answer.title }}</div>
|
||||||
|
<div class="search__answer-summary">{{ answer.summary }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search__answer-assistant"
|
||||||
|
data-answer-action="ask-ai"
|
||||||
|
(click)="openAssistantForAnswerPanel()"
|
||||||
|
>
|
||||||
|
{{ t('ui.search.answer.ask_ai', 'Ask AdvisoryAI') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="search__answer-evidence">{{ answer.evidence }}</div>
|
||||||
|
|
||||||
|
@if (answer.citations.length > 0) {
|
||||||
|
<div class="search__answer-citations">
|
||||||
|
@for (citation of answer.citations; track citation.key) {
|
||||||
|
<span class="search__answer-citation" data-answer-citation>{{ citation.title }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (answer.questions.length > 0) {
|
||||||
|
<div class="search__answer-questions">
|
||||||
|
<div class="search__group-label">{{ answer.questionLabel }}</div>
|
||||||
|
<div class="search__question-chips">
|
||||||
|
@for (question of answer.questions; track question.query) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search__question-chip"
|
||||||
|
[class.search__question-chip--clarify]="question.kind === 'clarify'"
|
||||||
|
[attr.data-answer-question]="question.kind"
|
||||||
|
(click)="applyQuestionQuery(question.query, answer.status === 'clarify' ? 'clarify' : 'answer')"
|
||||||
|
>
|
||||||
|
{{ question.query }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (answer.nextSearches.length > 0) {
|
||||||
|
<div class="search__answer-next">
|
||||||
|
<div class="search__group-label">{{ t('ui.search.answer.next_searches', 'Search next') }}</div>
|
||||||
|
<div class="search__answer-next-actions">
|
||||||
|
@for (suggestion of answer.nextSearches; track suggestion.query) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search__answer-next-search"
|
||||||
|
data-answer-next-search
|
||||||
|
(click)="applyAnswerNextSearch(suggestion.query)"
|
||||||
|
>
|
||||||
|
{{ suggestion.query }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
<div class="search__loading">{{ t('ui.search.loading', 'Searching...') }}</div>
|
<div class="search__loading">{{ t('ui.search.loading', 'Searching...') }}</div>
|
||||||
} @else if (query().trim().length >= 1 && cards().length === 0) {
|
} @else if (query().trim().length >= 1 && cards().length === 0) {
|
||||||
@@ -286,6 +376,24 @@ type RescueActionView = {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (commonQuestions().length > 0) {
|
||||||
|
<div class="search__questions">
|
||||||
|
<div class="search__group-label">{{ t('ui.search.questions.label', 'Common questions') }}</div>
|
||||||
|
<div class="search__question-chips">
|
||||||
|
@for (question of commonQuestions(); track question.query) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search__question-chip"
|
||||||
|
[attr.data-common-question]="question.kind"
|
||||||
|
(click)="applyQuestionQuery(question.query, 'common')"
|
||||||
|
>
|
||||||
|
{{ question.query }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="search__suggestions">
|
<div class="search__suggestions">
|
||||||
<div class="search__group-label">{{ t('ui.search.suggested_label', 'Suggested') }}</div>
|
<div class="search__group-label">{{ t('ui.search.suggested_label', 'Suggested') }}</div>
|
||||||
<div class="search__suggestion-cards">
|
<div class="search__suggestion-cards">
|
||||||
@@ -673,6 +781,162 @@ type RescueActionView = {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search__questions {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__question-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__question-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s, border-color 0.1s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__question-chip:hover {
|
||||||
|
border-color: var(--color-brand-primary, #1d4ed8);
|
||||||
|
background: var(--color-brand-primary-10, #eff6ff);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__question-chip--clarify {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer {
|
||||||
|
margin: 0.625rem 0.75rem 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-brand-primary-20, #bfdbfe);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: linear-gradient(135deg, var(--color-brand-primary-10, #eff6ff) 0%, var(--color-surface-primary) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer--clarify {
|
||||||
|
border-color: #fbbf24;
|
||||||
|
background: linear-gradient(135deg, #fffbeb 0%, var(--color-surface-primary) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer--insufficient {
|
||||||
|
border-color: var(--color-border-primary);
|
||||||
|
background: linear-gradient(135deg, var(--color-surface-tertiary) 0%, var(--color-surface-primary) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-eyebrow {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-summary {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-assistant {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border: 1px solid var(--color-brand-primary-20, #bfdbfe);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: var(--color-brand-primary, #1d4ed8);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-assistant:hover {
|
||||||
|
background: var(--color-brand-primary-10, #eff6ff);
|
||||||
|
border-color: var(--color-brand-primary, #1d4ed8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-evidence {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-citations {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-citation {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.1875rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-questions,
|
||||||
|
.search__answer-next {
|
||||||
|
margin-top: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-next-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-next-search {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__answer-next-search:hover {
|
||||||
|
border-color: var(--color-brand-primary, #1d4ed8);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-brand-primary-10, #eff6ff);
|
||||||
|
}
|
||||||
|
|
||||||
.search__suggestions {
|
.search__suggestions {
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
@@ -777,6 +1041,10 @@ type RescueActionView = {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search__answer-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.search__suggestion-cards {
|
.search__suggestion-cards {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -978,6 +1246,12 @@ type RescueActionView = {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search__question-chip,
|
||||||
|
.search__answer-assistant,
|
||||||
|
.search__answer-next-search {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.search__segment,
|
.search__segment,
|
||||||
.search__scope-chip,
|
.search__scope-chip,
|
||||||
.search__rescue-card {
|
.search__rescue-card {
|
||||||
@@ -1185,6 +1459,107 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly commonQuestions = computed<SearchQuestionView[]>(() => {
|
||||||
|
const mode = this.experienceMode();
|
||||||
|
return [...this.ambientContext.getCommonQuestions(mode)]
|
||||||
|
.sort((left, right) =>
|
||||||
|
this.scoreQuestionForMode(right, mode) - this.scoreQuestionForMode(left, mode))
|
||||||
|
.map((question) => ({
|
||||||
|
query: this.i18n.tryT(question.key) ?? question.fallback,
|
||||||
|
kind: question.kind ?? 'page',
|
||||||
|
preferredModes: question.preferredModes,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly clarifyingQuestions = computed<SearchQuestionView[]>(() => {
|
||||||
|
const mode = this.experienceMode();
|
||||||
|
return [...this.ambientContext.getClarifyingQuestions(mode)]
|
||||||
|
.sort((left, right) =>
|
||||||
|
this.scoreQuestionForMode(right, mode) - this.scoreQuestionForMode(left, mode))
|
||||||
|
.map((question) => ({
|
||||||
|
query: this.i18n.tryT(question.key) ?? question.fallback,
|
||||||
|
kind: question.kind ?? 'clarify',
|
||||||
|
preferredModes: question.preferredModes,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly searchAnswer = computed<SearchAnswerView | null>(() => {
|
||||||
|
const query = this.query().trim();
|
||||||
|
if (!query || this.isLoading()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = this.searchResponse();
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = this.experienceMode();
|
||||||
|
const pageLabel = this.searchContextPanel()?.title ?? this.t('ui.search.answer.context.default', 'Current page');
|
||||||
|
const modeLabel = this.experienceModeOptions().find((option) => option.id === mode)?.label ?? mode;
|
||||||
|
const eyebrow = `${pageLabel} | ${modeLabel}`;
|
||||||
|
const nextSearches = this.contextualSuggestions()
|
||||||
|
.filter((suggestion) => suggestion.query.trim().toLowerCase() !== query.toLowerCase())
|
||||||
|
.slice(0, 2);
|
||||||
|
const hasGroundedEvidence = response.cards.length > 0 || (response.synthesis?.sourceCount ?? 0) > 0;
|
||||||
|
|
||||||
|
if (hasGroundedEvidence) {
|
||||||
|
return {
|
||||||
|
status: 'grounded',
|
||||||
|
eyebrow,
|
||||||
|
title: this.answerTitleForMode(mode),
|
||||||
|
summary: this.buildGroundedAnswerSummary(response),
|
||||||
|
evidence: this.buildGroundedEvidenceLabel(response),
|
||||||
|
citations: this.buildAnswerCitations(response),
|
||||||
|
questionLabel: this.t('ui.search.answer.questions.follow_up', 'Ask next'),
|
||||||
|
questions: this.commonQuestions().slice(0, 3),
|
||||||
|
nextSearches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clarifyingQuestions = this.clarifyingQuestions();
|
||||||
|
if (clarifyingQuestions.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'clarify',
|
||||||
|
eyebrow,
|
||||||
|
title: this.t('ui.search.answer.title.clarify', 'Tighten the question'),
|
||||||
|
summary: this.t(
|
||||||
|
'ui.search.answer.summary.clarify',
|
||||||
|
'I could not form a grounded answer for "{query}" in {page}. Narrow the entity, time window, or scope.',
|
||||||
|
{ query, page: pageLabel },
|
||||||
|
),
|
||||||
|
evidence: this.t(
|
||||||
|
'ui.search.answer.evidence.clarify',
|
||||||
|
'No direct grounded answer was found in {scope}.',
|
||||||
|
{ scope: this.searchScopeLabel() },
|
||||||
|
),
|
||||||
|
citations: [],
|
||||||
|
questionLabel: this.t('ui.search.answer.questions.clarify', 'Clarify with one of these'),
|
||||||
|
questions: clarifyingQuestions,
|
||||||
|
nextSearches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'insufficient',
|
||||||
|
eyebrow,
|
||||||
|
title: this.t('ui.search.answer.title.insufficient', 'Not enough evidence yet'),
|
||||||
|
summary: this.t(
|
||||||
|
'ui.search.answer.summary.insufficient',
|
||||||
|
'Search did not find enough evidence to answer "{query}" from the current context. Try a stronger entity, blocker, or time-bound question.',
|
||||||
|
{ query },
|
||||||
|
),
|
||||||
|
evidence: this.t(
|
||||||
|
'ui.search.answer.evidence.insufficient',
|
||||||
|
'Use a follow-up question, broaden the scope, or ask AdvisoryAI to help frame the next search.',
|
||||||
|
),
|
||||||
|
citations: [],
|
||||||
|
questionLabel: this.t('ui.search.answer.questions.retry', 'Try one of these questions'),
|
||||||
|
questions: this.commonQuestions().slice(0, 2),
|
||||||
|
nextSearches,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
readonly rescueActions = computed<RescueActionView[]>(() => {
|
readonly rescueActions = computed<RescueActionView[]>(() => {
|
||||||
const query = this.query().trim();
|
const query = this.query().trim();
|
||||||
const pageLabel = this.searchContextPanel()?.title ?? 'current page';
|
const pageLabel = this.searchContextPanel()?.title ?? 'current page';
|
||||||
@@ -1598,6 +1973,29 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
this.searchTerms$.next(example.trim());
|
this.searchTerms$.next(example.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyQuestionQuery(query: string, source: 'common' | 'answer' | 'clarify'): void {
|
||||||
|
const action = source === 'common'
|
||||||
|
? 'search_common_question'
|
||||||
|
: source === 'clarify'
|
||||||
|
? 'search_answer_clarify'
|
||||||
|
: 'search_answer_question';
|
||||||
|
this.recordAmbientAction(action, {
|
||||||
|
source: 'global_search_self_serve',
|
||||||
|
queryHint: query,
|
||||||
|
});
|
||||||
|
this.query.set(query);
|
||||||
|
this.searchTerms$.next(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAnswerNextSearch(query: string): void {
|
||||||
|
this.recordAmbientAction('search_answer_next_search', {
|
||||||
|
source: 'global_search_self_serve',
|
||||||
|
queryHint: query,
|
||||||
|
});
|
||||||
|
this.query.set(query);
|
||||||
|
this.searchTerms$.next(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
applySuggestion(text: string): void {
|
applySuggestion(text: string): void {
|
||||||
this.recordAmbientAction('search_suggestion', {
|
this.recordAmbientAction('search_suggestion', {
|
||||||
source: 'global_search_did_you_mean',
|
source: 'global_search_did_you_mean',
|
||||||
@@ -1981,6 +2379,98 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scoreQuestionForMode(
|
||||||
|
question: {
|
||||||
|
kind?: 'page' | 'clarify' | 'recent';
|
||||||
|
preferredModes?: readonly SearchExperienceMode[];
|
||||||
|
},
|
||||||
|
mode: SearchExperienceMode,
|
||||||
|
): number {
|
||||||
|
let score = 0;
|
||||||
|
if (question.preferredModes?.includes(mode)) {
|
||||||
|
score += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'act' && question.kind === 'recent') {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'explain' && question.kind === 'page') {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private answerTitleForMode(mode: SearchExperienceMode): string {
|
||||||
|
switch (mode) {
|
||||||
|
case 'explain':
|
||||||
|
return this.t('ui.search.answer.title.explain', 'What it means');
|
||||||
|
case 'act':
|
||||||
|
return this.t('ui.search.answer.title.act', 'Recommended next step');
|
||||||
|
default:
|
||||||
|
return this.t('ui.search.answer.title.find', 'What we found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGroundedAnswerSummary(response: UnifiedSearchResponse): string {
|
||||||
|
const synthesisSummary = response.synthesis?.summary?.trim();
|
||||||
|
if (synthesisSummary) {
|
||||||
|
return synthesisSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topCard = response.cards[0];
|
||||||
|
if (!topCard) {
|
||||||
|
return this.t('ui.search.answer.summary.grounded.default', 'Relevant evidence was found for this query.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.experienceMode() === 'act') {
|
||||||
|
return this.t(
|
||||||
|
'ui.search.answer.summary.grounded.act',
|
||||||
|
'{title} is the strongest next lead. Use the result actions below to inspect or act.',
|
||||||
|
{ title: topCard.title },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return topCard.snippet?.trim() || topCard.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGroundedEvidenceLabel(response: UnifiedSearchResponse): string {
|
||||||
|
const sourceCount = Math.max(response.synthesis?.sourceCount ?? 0, response.cards.length);
|
||||||
|
const domains = response.synthesis?.domainsCovered?.length
|
||||||
|
? response.synthesis.domainsCovered
|
||||||
|
: [...new Set(response.cards.map((card) => DOMAIN_LABELS[card.domain] ?? card.domain))];
|
||||||
|
const domainLabel = domains.slice(0, 3).join(', ') || this.t('ui.search.answer.domain.default', 'current scope');
|
||||||
|
|
||||||
|
return this.t(
|
||||||
|
'ui.search.answer.evidence.grounded',
|
||||||
|
'Grounded in {count} source(s) across {domains}.',
|
||||||
|
{ count: sourceCount, domains: domainLabel },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAnswerCitations(response: UnifiedSearchResponse): Array<{ key: string; title: string }> {
|
||||||
|
const citations = response.synthesis?.citations ?? [];
|
||||||
|
if (citations.length > 0) {
|
||||||
|
return citations
|
||||||
|
.map((citation) => {
|
||||||
|
const matchingCard = response.cards.find((card) => card.entityKey === citation.entityKey);
|
||||||
|
return {
|
||||||
|
key: citation.entityKey,
|
||||||
|
title: matchingCard?.title ?? citation.title,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.cards
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((card) => ({
|
||||||
|
key: card.entityKey,
|
||||||
|
title: card.title,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private buildModeAwareAlternativeQuery(): string | null {
|
private buildModeAwareAlternativeQuery(): string | null {
|
||||||
const currentQuery = this.query().trim().toLowerCase();
|
const currentQuery = this.query().trim().toLowerCase();
|
||||||
const mode = this.experienceMode();
|
const mode = this.experienceMode();
|
||||||
@@ -2030,6 +2520,32 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openAssistantForAnswerPanel(): void {
|
||||||
|
const query = this.query().trim();
|
||||||
|
const pageLabel = this.searchContextPanel()?.title ?? 'current page';
|
||||||
|
const directive = this.searchExperienceMode.definition().assistantDirective;
|
||||||
|
const answer = this.searchAnswer();
|
||||||
|
const suggestedPrompt = answer?.status === 'grounded'
|
||||||
|
? `I searched for "${query}" on ${pageLabel}. ${directive} Expand the grounded answer, explain the evidence, and tell me the safest next step.`
|
||||||
|
: `I searched for "${query}" on ${pageLabel}. ${directive} Help me clarify the query, explain what evidence is missing, and propose the best next search.`;
|
||||||
|
|
||||||
|
this.recordAmbientAction('search_answer_to_chat', {
|
||||||
|
source: 'global_search_answer_panel',
|
||||||
|
queryHint: query || pageLabel,
|
||||||
|
});
|
||||||
|
this.searchChatContext.setSearchToChat({
|
||||||
|
query: query || pageLabel,
|
||||||
|
entityCards: this.cards(),
|
||||||
|
synthesis: this.synthesis(),
|
||||||
|
suggestedPrompt,
|
||||||
|
mode: this.experienceMode(),
|
||||||
|
});
|
||||||
|
this.closeResults();
|
||||||
|
void this.router.navigate(['/security/triage'], {
|
||||||
|
queryParams: { openChat: 'true', q: query || pageLabel },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private resolveSuggestionReason(suggestion: {
|
private resolveSuggestionReason(suggestion: {
|
||||||
reasonKey?: string;
|
reasonKey?: string;
|
||||||
reasonFallback?: string;
|
reasonFallback?: string;
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ describe('AmbientContextService', () => {
|
|||||||
expect(keys).toContain('ui.search.suggestion.findings.unresolved');
|
expect(keys).toContain('ui.search.suggestion.findings.unresolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns page-owned common questions for the active route', () => {
|
||||||
|
const service = TestBed.inject(AmbientContextService);
|
||||||
|
const questions = service.getCommonQuestions('explain').map((item) => item.fallback);
|
||||||
|
|
||||||
|
expect(questions).toContain('Why is this exploitable in my environment?');
|
||||||
|
expect(questions).toContain('What evidence blocks this release?');
|
||||||
|
expect(questions).toContain('What is the safest remediation path?');
|
||||||
|
});
|
||||||
|
|
||||||
it('updates search and chat suggestion sets when route changes', () => {
|
it('updates search and chat suggestion sets when route changes', () => {
|
||||||
const service = TestBed.inject(AmbientContextService);
|
const service = TestBed.inject(AmbientContextService);
|
||||||
|
|
||||||
@@ -98,6 +107,23 @@ describe('AmbientContextService', () => {
|
|||||||
expect(followUps).toContain('follow up: CVE-2024-21626');
|
expect(followUps).toContain('follow up: CVE-2024-21626');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds a recent-action question for the active mode', () => {
|
||||||
|
const service = TestBed.inject(AmbientContextService);
|
||||||
|
service.recordAction({
|
||||||
|
action: 'search_result_open',
|
||||||
|
queryHint: 'CVE-2024-21626',
|
||||||
|
domain: 'findings',
|
||||||
|
});
|
||||||
|
|
||||||
|
const questions = service.getCommonQuestions('act');
|
||||||
|
expect(questions[0]).toEqual(jasmine.objectContaining({
|
||||||
|
key: 'ui.search.question.recent_action.default',
|
||||||
|
fallback: 'What should I do next for CVE-2024-21626?',
|
||||||
|
kind: 'recent',
|
||||||
|
preferredModes: ['act'],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('expires stale last-action suggestions after TTL', () => {
|
it('expires stale last-action suggestions after TTL', () => {
|
||||||
const service = TestBed.inject(AmbientContextService);
|
const service = TestBed.inject(AmbientContextService);
|
||||||
service.recordAction({
|
service.recordAction({
|
||||||
@@ -167,4 +193,15 @@ describe('AmbientContextService', () => {
|
|||||||
}),
|
}),
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns route-specific clarifying questions after navigation changes', () => {
|
||||||
|
const service = TestBed.inject(AmbientContextService);
|
||||||
|
|
||||||
|
router.url = '/ops/policy';
|
||||||
|
events.next(new NavigationEnd(1, '/ops/policy', '/ops/policy'));
|
||||||
|
|
||||||
|
const questions = service.getClarifyingQuestions('act').map((item) => item.fallback);
|
||||||
|
expect(questions).toContain('Which rule, environment, or control should I narrow this to?');
|
||||||
|
expect(questions).toContain('Do you want recent failures, exceptions, or promotion impact?');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ describe('GlobalSearchComponent', () => {
|
|||||||
'buildContextFilter',
|
'buildContextFilter',
|
||||||
'getSearchContextPanel',
|
'getSearchContextPanel',
|
||||||
'getSearchSuggestions',
|
'getSearchSuggestions',
|
||||||
|
'getCommonQuestions',
|
||||||
|
'getClarifyingQuestions',
|
||||||
'buildAmbientContext',
|
'buildAmbientContext',
|
||||||
'recordAction',
|
'recordAction',
|
||||||
]) as jasmine.SpyObj<AmbientContextService>;
|
]) as jasmine.SpyObj<AmbientContextService>;
|
||||||
@@ -101,6 +103,40 @@ describe('GlobalSearchComponent', () => {
|
|||||||
kind: 'page',
|
kind: 'page',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
ambientContext.getCommonQuestions.and.returnValue([
|
||||||
|
{
|
||||||
|
key: 'ui.search.question.findings.exploitable',
|
||||||
|
fallback: 'Why is this exploitable in my environment?',
|
||||||
|
kind: 'page',
|
||||||
|
preferredModes: ['explain'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ui.search.question.findings.release_blocker',
|
||||||
|
fallback: 'What evidence blocks this release?',
|
||||||
|
kind: 'page',
|
||||||
|
preferredModes: ['act', 'explain'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ui.search.question.findings.remediation',
|
||||||
|
fallback: 'What is the safest remediation path?',
|
||||||
|
kind: 'page',
|
||||||
|
preferredModes: ['act'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
ambientContext.getClarifyingQuestions.and.returnValue([
|
||||||
|
{
|
||||||
|
key: 'ui.search.question.findings.clarify_target',
|
||||||
|
fallback: 'Which CVE, workload, or package should I narrow this to?',
|
||||||
|
kind: 'clarify',
|
||||||
|
preferredModes: ['find', 'explain'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ui.search.question.findings.clarify_scope',
|
||||||
|
fallback: 'Should I focus on reachable, production, or unresolved findings?',
|
||||||
|
kind: 'clarify',
|
||||||
|
preferredModes: ['find', 'act'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
ambientContext.buildAmbientContext.and.returnValue({
|
ambientContext.buildAmbientContext.and.returnValue({
|
||||||
currentRoute: '/security/triage',
|
currentRoute: '/security/triage',
|
||||||
recentSearches: [],
|
recentSearches: [],
|
||||||
@@ -176,6 +212,19 @@ describe('GlobalSearchComponent', () => {
|
|||||||
expect(suggestionReason?.textContent?.trim()).toBe('Useful starting points across Stella Ops.');
|
expect(suggestionReason?.textContent?.trim()).toBe('Useful starting points across Stella Ops.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders page-owned common questions in the empty state', () => {
|
||||||
|
component.onFocus();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const questionButtons = Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('[data-common-question]') as NodeListOf<HTMLButtonElement>,
|
||||||
|
).map((node) => node.textContent?.trim());
|
||||||
|
|
||||||
|
expect(questionButtons).toContain('Why is this exploitable in my environment?');
|
||||||
|
expect(questionButtons).toContain('What evidence blocks this release?');
|
||||||
|
expect(questionButtons).toContain('What is the safest remediation path?');
|
||||||
|
});
|
||||||
|
|
||||||
it('queries unified search for one-character query terms', async () => {
|
it('queries unified search for one-character query terms', async () => {
|
||||||
component.onFocus();
|
component.onFocus();
|
||||||
component.onQueryChange('a');
|
component.onQueryChange('a');
|
||||||
@@ -390,6 +439,65 @@ describe('GlobalSearchComponent', () => {
|
|||||||
expect(rescueCards.length).toBe(4);
|
expect(rescueCards.length).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a grounded answer panel before search results', async () => {
|
||||||
|
searchClient.search.and.returnValue(of({
|
||||||
|
query: 'critical findings',
|
||||||
|
topK: 10,
|
||||||
|
cards: [createCard('findings', '/triage/findings/fnd-101')],
|
||||||
|
synthesis: {
|
||||||
|
summary: 'One critical finding matched the current page context.',
|
||||||
|
template: 'finding_overview',
|
||||||
|
confidence: 'high',
|
||||||
|
sourceCount: 2,
|
||||||
|
domainsCovered: ['Findings', 'Policy'],
|
||||||
|
citations: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
entityKey: 'findings:sample',
|
||||||
|
title: 'findings sample',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
ftsMatches: 1,
|
||||||
|
vectorMatches: 0,
|
||||||
|
entityCardCount: 1,
|
||||||
|
durationMs: 5,
|
||||||
|
usedVector: false,
|
||||||
|
mode: 'fts-only',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
component.onFocus();
|
||||||
|
component.onQueryChange('critical findings');
|
||||||
|
await waitForDebounce();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="grounded"]') as HTMLElement | null;
|
||||||
|
expect(answerPanel).not.toBeNull();
|
||||||
|
expect(answerPanel?.textContent).toContain('What we found');
|
||||||
|
expect(answerPanel?.textContent).toContain('One critical finding matched the current page context.');
|
||||||
|
expect(answerPanel?.textContent).toContain('Grounded in 2 source(s) across Findings, Policy.');
|
||||||
|
expect(answerPanel?.textContent).toContain('findings sample');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a clarify answer panel when no grounded evidence is found', async () => {
|
||||||
|
component.onFocus();
|
||||||
|
component.onQueryChange('mystery issue');
|
||||||
|
await waitForDebounce();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const answerPanel = fixture.nativeElement.querySelector('[data-answer-status="clarify"]') as HTMLElement | null;
|
||||||
|
const answerQuestions = Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('[data-answer-question]') as NodeListOf<HTMLButtonElement>,
|
||||||
|
).map((node) => node.textContent?.trim());
|
||||||
|
|
||||||
|
expect(answerPanel).not.toBeNull();
|
||||||
|
expect(answerPanel?.textContent).toContain('Tighten the question');
|
||||||
|
expect(answerQuestions).toContain('Which CVE, workload, or package should I narrow this to?');
|
||||||
|
expect(answerQuestions).toContain('Should I focus on reachable, production, or unresolved findings?');
|
||||||
|
});
|
||||||
|
|
||||||
it('retries the active query globally when scope rescue toggles off page filtering', async () => {
|
it('retries the active query globally when scope rescue toggles off page filtering', async () => {
|
||||||
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);
|
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildResponse,
|
||||||
|
emptyResponse,
|
||||||
|
findingCard,
|
||||||
|
setupAuthenticatedSession,
|
||||||
|
setupBasicMocks,
|
||||||
|
typeInSearch,
|
||||||
|
waitForEntityCards,
|
||||||
|
waitForResults,
|
||||||
|
} from './unified-search-fixtures';
|
||||||
|
|
||||||
|
const groundedFindingResponse = {
|
||||||
|
query: 'critical findings',
|
||||||
|
topK: 10,
|
||||||
|
cards: [
|
||||||
|
findingCard({
|
||||||
|
cveId: 'CVE-2024-21626',
|
||||||
|
title: 'CVE-2024-21626 in api-gateway',
|
||||||
|
snippet: 'Reachable critical vulnerability detected in the current production path.',
|
||||||
|
severity: 'critical',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
synthesis: {
|
||||||
|
summary: 'A reachable critical finding is blocking the current workflow and policy review is warranted.',
|
||||||
|
template: 'finding_overview',
|
||||||
|
confidence: 'high',
|
||||||
|
sourceCount: 2,
|
||||||
|
domainsCovered: ['Findings', 'Policy'],
|
||||||
|
citations: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
entityKey: 'cve:CVE-2024-21626',
|
||||||
|
title: 'CVE-2024-21626 in api-gateway',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
ftsMatches: 3,
|
||||||
|
vectorMatches: 1,
|
||||||
|
entityCardCount: 1,
|
||||||
|
durationMs: 33,
|
||||||
|
usedVector: true,
|
||||||
|
mode: 'hybrid',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const narrowedFindingResponse = buildResponse(
|
||||||
|
'Which CVE, workload, or package should I narrow this to?',
|
||||||
|
[
|
||||||
|
findingCard({
|
||||||
|
cveId: 'CVE-2023-38545',
|
||||||
|
title: 'CVE-2023-38545 in edge-router',
|
||||||
|
snippet: 'A narrowed finding result is now grounded and ready for review.',
|
||||||
|
severity: 'high',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
summary: 'Narrowing the question exposed a grounded finding answer.',
|
||||||
|
template: 'finding_overview',
|
||||||
|
confidence: 'high',
|
||||||
|
sourceCount: 1,
|
||||||
|
domainsCovered: ['Findings'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.describe('Unified Search - Self-Serve Answer Panel', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupBasicMocks(page);
|
||||||
|
await setupAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows page-owned common questions and a grounded answer panel', async ({ page }) => {
|
||||||
|
await mockSearchResponses(page, (query) =>
|
||||||
|
query.includes('critical findings') ? groundedFindingResponse : emptyResponse(query));
|
||||||
|
|
||||||
|
await page.goto('/security/triage');
|
||||||
|
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await page.locator('app-global-search input[type="text"]').focus();
|
||||||
|
await waitForResults(page);
|
||||||
|
await expect(page.locator('[data-common-question]')).toContainText([
|
||||||
|
'Why is this exploitable in my environment?',
|
||||||
|
'What evidence blocks this release?',
|
||||||
|
'What is the safest remediation path?',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await typeInSearch(page, 'critical findings');
|
||||||
|
await waitForResults(page);
|
||||||
|
await waitForEntityCards(page, 1);
|
||||||
|
|
||||||
|
const answerPanel = page.locator('[data-answer-status="grounded"]');
|
||||||
|
await expect(answerPanel).toBeVisible();
|
||||||
|
await expect(answerPanel).toContainText('What we found');
|
||||||
|
await expect(answerPanel).toContainText('A reachable critical finding is blocking the current workflow and policy review is warranted.');
|
||||||
|
await expect(answerPanel).toContainText('Grounded in 2 source(s) across Findings, Policy.');
|
||||||
|
await expect(answerPanel.locator('[data-answer-citation]')).toContainText(['CVE-2024-21626 in api-gateway']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses clarify questions to rerun search and recover a grounded answer', async ({ page }) => {
|
||||||
|
await mockSearchResponses(page, (query) => {
|
||||||
|
if (query.includes('which cve, workload, or package should i narrow this to?')) {
|
||||||
|
return narrowedFindingResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.includes('mystery issue')) {
|
||||||
|
return emptyResponse('mystery issue');
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyResponse(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/security/triage');
|
||||||
|
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await typeInSearch(page, 'mystery issue');
|
||||||
|
await waitForResults(page);
|
||||||
|
await expect(page.locator('[data-answer-status="clarify"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Which CVE, workload, or package should I narrow this to?' }).click();
|
||||||
|
await waitForResults(page);
|
||||||
|
await waitForEntityCards(page, 1);
|
||||||
|
|
||||||
|
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(
|
||||||
|
'Which CVE, workload, or package should I narrow this to?',
|
||||||
|
);
|
||||||
|
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(
|
||||||
|
'Narrowing the question exposed a grounded finding answer.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens AdvisoryAI from the answer panel with mode-aware context', async ({ page }) => {
|
||||||
|
await mockSearchResponses(page, (query) =>
|
||||||
|
query.includes('critical findings') ? groundedFindingResponse : emptyResponse(query));
|
||||||
|
await mockChatConversation(page, {
|
||||||
|
content: 'I can expand the grounded answer, explain the evidence, and recommend the safest next step.',
|
||||||
|
citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }],
|
||||||
|
groundingScore: 0.95,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/security/triage');
|
||||||
|
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await page.locator('app-global-search input[type="text"]').focus();
|
||||||
|
await waitForResults(page);
|
||||||
|
await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click();
|
||||||
|
await typeInSearch(page, 'critical findings');
|
||||||
|
await waitForResults(page);
|
||||||
|
await waitForEntityCards(page, 1);
|
||||||
|
|
||||||
|
await page.locator('[data-answer-action="ask-ai"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Act/i);
|
||||||
|
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/Expand the grounded answer/i);
|
||||||
|
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/critical findings/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mockSearchResponses(
|
||||||
|
page: Page,
|
||||||
|
resolve: (normalizedQuery: string) => unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.route('**/search/query**', async (route) => {
|
||||||
|
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||||
|
const query = String(body['q'] ?? '').toLowerCase();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(resolve(query)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockChatConversation(
|
||||||
|
page: Page,
|
||||||
|
response: {
|
||||||
|
content: string;
|
||||||
|
citations: Array<{ type: string; path: string; verified: boolean }>;
|
||||||
|
groundingScore: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
|
||||||
|
if (route.request().method() !== 'POST') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversationId: 'conv-self-serve-1',
|
||||||
|
tenantId: 'test-tenant',
|
||||||
|
userId: 'tester',
|
||||||
|
context: {},
|
||||||
|
turns: [],
|
||||||
|
createdAt: '2026-03-07T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-07T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/v1/advisory-ai/conversations/*/turns', async (route) => {
|
||||||
|
if (route.request().method() !== 'POST') {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
'event: progress',
|
||||||
|
'data: {"stage":"searching"}',
|
||||||
|
'',
|
||||||
|
'event: token',
|
||||||
|
`data: ${JSON.stringify({ content: response.content })}`,
|
||||||
|
'',
|
||||||
|
...response.citations.flatMap((citation) => ([
|
||||||
|
'event: citation',
|
||||||
|
`data: ${JSON.stringify(citation)}`,
|
||||||
|
'',
|
||||||
|
])),
|
||||||
|
'event: done',
|
||||||
|
`data: ${JSON.stringify({ turnId: 'turn-self-serve-1', groundingScore: response.groundingScore })}`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/event-stream; charset=utf-8',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
},
|
||||||
|
body: events,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user