From 803940bd362c9421aff9876a3ccef79712b2961f Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 01:21:14 +0200 Subject: [PATCH] Add answer-first self-serve search UX --- ...7_004_FE_self_serve_search_answer_first.md | 118 ++++ ...AI_grounded_search_answer_orchestration.md | 107 ++++ ...6_FE_self_serve_rollout_and_gap_closure.md | 109 ++++ docs/modules/advisory-ai/knowledge-search.md | 2 + docs/modules/ui/TASKS.md | 12 + docs/modules/ui/architecture.md | 4 +- docs/modules/ui/implementation_plan.md | 3 +- .../ui/search-chip-context-contract.md | 1 + docs/modules/ui/search-self-serve-contract.md | 65 +++ .../core/services/ambient-context.service.ts | 74 ++- .../core/services/search-context.registry.ts | 147 +++++ .../global-search/global-search.component.ts | 516 ++++++++++++++++++ .../context/ambient-context.service.spec.ts | 37 ++ .../global-search.component.spec.ts | 108 ++++ ...search-self-serve-answer-panel.e2e.spec.ts | 239 ++++++++ 15 files changed, 1536 insertions(+), 6 deletions(-) create mode 100644 docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md create mode 100644 docs/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md create mode 100644 docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md create mode 100644 docs/modules/ui/search-self-serve-contract.md create mode 100644 src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts diff --git a/docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md b/docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md new file mode 100644 index 000000000..15da58cd2 --- /dev/null +++ b/docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md @@ -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`. diff --git a/docs/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md b/docs/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md new file mode 100644 index 000000000..b72c9c8b3 --- /dev/null +++ b/docs/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.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. diff --git a/docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md b/docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md new file mode 100644 index 000000000..f1b6de55d --- /dev/null +++ b/docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md @@ -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. diff --git a/docs/modules/advisory-ai/knowledge-search.md b/docs/modules/advisory-ai/knowledge-search.md index 1b43099ed..2f68595a6 100644 --- a/docs/modules/advisory-ai/knowledge-search.md +++ b/docs/modules/advisory-ai/knowledge-search.md @@ -172,6 +172,8 @@ Global search now consumes AKS and supports: - Doctor: `Run` (navigate to doctor and copy run command). - `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. +- 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. - 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). diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 98c03f660..83b6c9e9c 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -4,6 +4,8 @@ - `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_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 - [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-004 AdvisoryAI evidence-first next-step cards - [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] 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) diff --git a/docs/modules/ui/architecture.md b/docs/modules/ui/architecture.md index 9a1ce22b2..22b2579ee 100644 --- a/docs/modules/ui/architecture.md +++ b/docs/modules/ui/architecture.md @@ -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. * **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. +* **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. * **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. * **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. --- diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index 21cc5a50c..bacd776d1 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -8,7 +8,8 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - Update this file when new scoped work is approved. ## 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 - `docs/modules/ui/architecture.md` diff --git a/docs/modules/ui/search-chip-context-contract.md b/docs/modules/ui/search-chip-context-contract.md index a2f582a62..b21a7ebbe 100644 --- a/docs/modules/ui/search-chip-context-contract.md +++ b/docs/modules/ui/search-chip-context-contract.md @@ -4,6 +4,7 @@ - Define one deterministic contract for page-aware search chips. - Let feature teams add page suggestions without editing `AmbientContextService` logic. - 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) - Every page that needs custom search chips must declare a context entry in `SEARCH_CONTEXT_DEFINITIONS`. diff --git a/docs/modules/ui/search-self-serve-contract.md b/docs/modules/ui/search-self-serve-contract.md new file mode 100644 index 000000000..bef6b3bcb --- /dev/null +++ b/docs/modules/ui/search-self-serve-contract.md @@ -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. diff --git a/src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts b/src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts index b7cdf9460..de955d88f 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts @@ -9,14 +9,19 @@ import type { UnifiedSearchFilter, } from '../api/unified-search.models'; import { + DEFAULT_CLARIFYING_QUESTIONS, + DEFAULT_COMMON_QUESTIONS, DEFAULT_CHAT_SUGGESTIONS, DEFAULT_SEARCH_SUGGESTIONS, SEARCH_CONTEXT_DEFINITIONS, type SearchContextDefinition, + type SearchQuestionChip, type SearchSuggestionChip, } from './search-context.registry'; +import type { SearchExperienceMode } from './search-experience-mode.service'; export type ContextSuggestion = SearchSuggestionChip; +export type ContextQuestion = SearchQuestionChip; export interface SearchContextPanelToken { key: string; @@ -114,7 +119,7 @@ export class AmbientContextService { const recentActions = this.getActiveActions(scopeKey); const actionSuggestions = this.buildRecentActionSuggestions(recentActions, 2); 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] .filter((entry): entry is ContextSuggestion => entry !== null) @@ -125,6 +130,39 @@ export class AmbientContextService { 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[] { const route = this.routeUrl(); 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 { const hint = this.buildActionHint(action); const normalizedAction = action.action.trim().toLowerCase(); @@ -511,10 +577,10 @@ export class AmbientContextService { } } - private rotateSuggestions( - suggestions: readonly ContextSuggestion[], + private rotateEntries( + suggestions: readonly T[], scope: string, - ): readonly ContextSuggestion[] { + ): readonly T[] { if (suggestions.length <= 1) { return suggestions; } diff --git a/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts b/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts index 328e99a7c..ac62ab7da 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts @@ -2,6 +2,7 @@ import type { UnifiedSearchDomain } from '../api/unified-search.models'; import type { SearchExperienceMode } from './search-experience-mode.service'; export type SearchSuggestionKind = 'page' | 'recent' | 'strategy'; +export type SearchQuestionKind = 'page' | 'clarify' | 'recent'; export interface SearchSuggestionChip { key: string; @@ -12,6 +13,18 @@ export interface SearchSuggestionChip { 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 { titleKey: string; titleFallback: string; @@ -26,6 +39,7 @@ export interface SearchContextDefinition { domain?: UnifiedSearchDomain; searchSuggestions?: readonly SearchSuggestionChip[]; chatSuggestions?: readonly SearchSuggestionChip[]; + selfServe?: SearchSelfServeDefinition; 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([ { 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'] }, @@ -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?' }, ]; +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([ { key: 'ui.search.suggestion.findings.critical', fallback: 'critical findings', preferredModes: ['find'] }, { 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?' }, ]; +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[] = [ { id: 'findings', @@ -120,6 +259,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ }, domain: 'findings', searchSuggestions: FINDINGS_SEARCH_SUGGESTIONS, + selfServe: FINDINGS_SELF_SERVE, }, { id: 'findings-chat-detail', @@ -137,6 +277,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ descriptionFallback: 'Search vendor statements, affected ranges, and disposition evidence.', }, domain: 'vex', + selfServe: VEX_SELF_SERVE, }, { id: 'policy', @@ -150,6 +291,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ domain: 'policy', searchSuggestions: POLICY_SEARCH_SUGGESTIONS, chatSuggestions: POLICY_CHAT_SUGGESTIONS, + selfServe: POLICY_SELF_SERVE, }, { id: 'doctor', @@ -162,6 +304,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ }, domain: 'knowledge', searchSuggestions: DOCTOR_SEARCH_SUGGESTIONS, + selfServe: DOCTOR_SELF_SERVE, }, { id: 'graph', @@ -173,6 +316,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ descriptionFallback: 'Follow dependency and reachability paths across the platform.', }, domain: 'graph', + selfServe: GRAPH_SELF_SERVE, }, { id: 'ops-memory', @@ -184,6 +328,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ descriptionFallback: 'Search recurring incidents, jobs, and learned operator runbooks.', }, domain: 'ops_memory', + selfServe: OPS_MEMORY_SELF_SERVE, }, { id: 'timeline', @@ -196,6 +341,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ }, domain: 'timeline', searchSuggestions: TIMELINE_SEARCH_SUGGESTIONS, + selfServe: TIMELINE_SELF_SERVE, }, { id: 'releases', @@ -207,5 +353,6 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ descriptionFallback: 'Investigate promotions, approvals, and blockers.', }, searchSuggestions: RELEASES_SEARCH_SUGGESTIONS, + selfServe: RELEASES_SELF_SERVE, }, ] as const; diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts index d98a93d07..45eea0691 100644 --- a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts @@ -48,6 +48,11 @@ type SearchSuggestionView = { kind: 'page' | 'recent' | 'strategy'; preferredModes?: readonly SearchExperienceMode[]; }; +type SearchQuestionView = { + query: string; + kind: 'page' | 'clarify' | 'recent'; + preferredModes?: readonly SearchExperienceMode[]; +}; type SearchContextPanelView = { title: string; description: string; @@ -62,6 +67,20 @@ type RescueActionView = { label: 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({ selector: 'app-global-search', @@ -134,6 +153,77 @@ type RescueActionView = { + @if (searchAnswer(); as answer) { +
+
{{ answer.eyebrow }}
+
+
+
{{ answer.title }}
+
{{ answer.summary }}
+
+ +
+
{{ answer.evidence }}
+ + @if (answer.citations.length > 0) { +
+ @for (citation of answer.citations; track citation.key) { + {{ citation.title }} + } +
+ } + + @if (answer.questions.length > 0) { +
+
{{ answer.questionLabel }}
+
+ @for (question of answer.questions; track question.query) { + + } +
+
+ } + + @if (answer.nextSearches.length > 0) { +
+
{{ t('ui.search.answer.next_searches', 'Search next') }}
+
+ @for (suggestion of answer.nextSearches; track suggestion.query) { + + } +
+
+ } +
+ } + @if (isLoading()) {
{{ t('ui.search.loading', 'Searching...') }}
} @else if (query().trim().length >= 1 && cards().length === 0) { @@ -286,6 +376,24 @@ type RescueActionView = { } + @if (commonQuestions().length > 0) { +
+
{{ t('ui.search.questions.label', 'Common questions') }}
+
+ @for (question of commonQuestions(); track question.query) { + + } +
+
+ } +
{{ t('ui.search.suggested_label', 'Suggested') }}
@@ -673,6 +781,162 @@ type RescueActionView = { 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 { padding: 0.5rem 0; border-bottom: 1px solid var(--color-border-primary); @@ -777,6 +1041,10 @@ type RescueActionView = { grid-template-columns: 1fr; } + .search__answer-header { + flex-direction: column; + } + .search__suggestion-cards { grid-template-columns: 1fr; } @@ -978,6 +1246,12 @@ type RescueActionView = { transition: none; } + .search__question-chip, + .search__answer-assistant, + .search__answer-next-search { + transition: none; + } + .search__segment, .search__scope-chip, .search__rescue-card { @@ -1185,6 +1459,107 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { })); }); + readonly commonQuestions = computed(() => { + 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(() => { + 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(() => { + 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(() => { const query = this.query().trim(); const pageLabel = this.searchContextPanel()?.title ?? 'current page'; @@ -1598,6 +1973,29 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { 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 { this.recordAmbientAction('search_suggestion', { source: 'global_search_did_you_mean', @@ -1981,6 +2379,98 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { 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 { const currentQuery = this.query().trim().toLowerCase(); 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: { reasonKey?: string; reasonFallback?: string; diff --git a/src/Web/StellaOps.Web/src/tests/context/ambient-context.service.spec.ts b/src/Web/StellaOps.Web/src/tests/context/ambient-context.service.spec.ts index 96b1d89f4..9dfeace24 100644 --- a/src/Web/StellaOps.Web/src/tests/context/ambient-context.service.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/context/ambient-context.service.spec.ts @@ -34,6 +34,15 @@ describe('AmbientContextService', () => { 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', () => { const service = TestBed.inject(AmbientContextService); @@ -98,6 +107,23 @@ describe('AmbientContextService', () => { 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', () => { const service = TestBed.inject(AmbientContextService); 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?'); + }); }); diff --git a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts index 24d11a82a..bacf62d0f 100644 --- a/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/global_search/global-search.component.spec.ts @@ -57,6 +57,8 @@ describe('GlobalSearchComponent', () => { 'buildContextFilter', 'getSearchContextPanel', 'getSearchSuggestions', + 'getCommonQuestions', + 'getClarifyingQuestions', 'buildAmbientContext', 'recordAction', ]) as jasmine.SpyObj; @@ -101,6 +103,40 @@ describe('GlobalSearchComponent', () => { 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({ currentRoute: '/security/triage', recentSearches: [], @@ -176,6 +212,19 @@ describe('GlobalSearchComponent', () => { 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, + ).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 () => { component.onFocus(); component.onQueryChange('a'); @@ -390,6 +439,65 @@ describe('GlobalSearchComponent', () => { 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, + ).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 () => { ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any); diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts new file mode 100644 index 000000000..2678d51d7 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts @@ -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 { + await page.route('**/search/query**', async (route) => { + const body = route.request().postDataJSON() as Record; + 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 { + 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, + }); + }); +}