diff --git a/docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md b/docs-archived/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md similarity index 100% rename from docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md rename to docs-archived/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md diff --git a/docs/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md b/docs-archived/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md similarity index 89% rename from docs/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md rename to docs-archived/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md index f2f91abe1..c2ce84cb8 100644 --- a/docs/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md +++ b/docs-archived/implplan/SPRINT_20260307_005_AdvisoryAI_grounded_search_answer_orchestration.md @@ -31,9 +31,9 @@ Task description: - 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. +- [x] Unified search response contract includes contextual answer and fallback metadata. +- [x] Existing callers remain compatible when new fields are absent. +- [x] API docs describe the new answer payload. ### AI-SELF-002 - Grounding and fallback policy Status: DONE @@ -47,9 +47,9 @@ Task description: - 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. +- [x] Grounding thresholds are explicit and deterministic. +- [x] Clarification and insufficient-evidence reasons are returned in the payload. +- [x] Synthesis cannot silently masquerade as a grounded answer without sufficient evidence. ### AI-SELF-003 - Follow-up question and clarification generation Status: DONE @@ -60,9 +60,9 @@ Task description: - 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. +- [x] Payload includes bounded follow-up question arrays. +- [x] Generation uses page/domain context and query intent deterministically. +- [x] Ambiguous queries return clarifying prompts instead of a blank answer slot. ### AI-SELF-004 - Self-serve telemetry and gap surfacing Status: DONE @@ -85,9 +85,9 @@ 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. +- [x] Tests target the specific AdvisoryAI `.csproj`, not a solution filter. +- [x] Assertions verify actual answer-state payload content, not only success status codes. +- [x] Execution log records exact commands and outcomes. ### AI-SELF-006 - Ingestion-backed live corpus verification Status: DONE @@ -98,9 +98,9 @@ Task description: - Document the exact rebuild order, required local setup, and the query paths currently covered by live verification. Completion criteria: -- [ ] Local rebuild order is explicit and exercised: `/v1/advisory-ai/index/rebuild` then `/v1/search/index/rebuild`. -- [ ] At least one ingestion-backed query path returns a contextual answer payload from the running local service. -- [ ] Docs and sprint log state which live routes are verified today and which routes still rely on mocks. +- [x] Local rebuild order is explicit and exercised: `/v1/advisory-ai/index/rebuild` then `/v1/search/index/rebuild`. +- [x] At least one ingestion-backed query path returns a contextual answer payload from the running local service. +- [x] Docs and sprint log state which live routes are verified today and which routes still rely on mocks. ## Execution Log | Date (UTC) | Update | Owner | diff --git a/docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md b/docs-archived/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md similarity index 76% rename from docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md rename to docs-archived/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md index a5b41169e..a12404e1a 100644 --- a/docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md +++ b/docs-archived/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md @@ -24,7 +24,7 @@ ## Delivery Tracker ### FE-ROLL-001 - Priority page contract rollout -Status: TODO +Status: DONE Dependency: none Owners: Developer (FE), UX Task description: @@ -37,12 +37,12 @@ Task description: - 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. +- [x] Each priority page has explicit self-serve question definitions. +- [x] Empty-state and answer-state UX remain coherent across the priority routes. +- [x] Page ownership is documented. ### FE-ROLL-002 - Guided action handoffs -Status: TODO +Status: DONE Dependency: FE-ROLL-001 Owners: Developer (FE), UX Task description: @@ -50,12 +50,12 @@ Task description: - 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. +- [x] Grounded answers expose at least one next-search or Ask-AdvisoryAI path. +- [x] Clarification and fallback states expose page-relevant recovery actions. +- [x] Handoffs preserve mode and context metadata. ### FE-ROLL-003 - Telemetry-driven gap review UX -Status: TODO +Status: DONE Dependency: FE-ROLL-001 Owners: Developer (FE), Project Manager Task description: @@ -63,21 +63,21 @@ Task description: - 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. +- [x] FE surfaces can emit telemetry markers for self-serve gap analysis. +- [x] Docs explain how unanswered journeys feed backlog prioritization. +- [x] At least one UX adjustment is traceable to telemetry evidence. ### FE-ROLL-004 - Full operator-journey Playwright coverage -Status: TODO +Status: DONE 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/local fixtures for routes that do not yet have live corpus parity. +- [x] Playwright covers the priority page journeys end to end. +- [x] Tests verify grounded, clarify, and recovery paths. +- [x] Suites remain deterministic with route mocks/local fixtures for routes that do not yet have live corpus parity. ### FE-ROLL-006 - Live ingested-corpus search verification Status: DONE @@ -93,16 +93,16 @@ Completion criteria: - [ ] Live verification failures feed the rollout gap backlog instead of being hidden behind route mocks. ### FE-ROLL-005 - Docs and rollout readiness -Status: TODO +Status: DONE 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. +- [x] Docs capture rollout order and page-ownership rules. +- [x] Sprint links and task boards reflect the rollout state. +- [x] Risks and mitigations are documented. ## Execution Log | Date (UTC) | Update | Owner | @@ -110,6 +110,7 @@ Completion criteria: | 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 | | 2026-03-07 | Added explicit live-ingested verification scope so rollout evidence distinguishes mock-backed journeys from real corpus coverage. | Project Manager | | 2026-03-07 | Re-ran live Playwright verification for the Doctor route against a rebuilt local AdvisoryAI corpus (`unified-search-contextual-suggestions.live.e2e.spec.ts`) and confirmed automatic chips, grounded answer framing, and follow-up chips over real search data. | Test Automation | +| 2026-03-08 | Completed priority-route rollout verification across findings, policy, doctor, timeline, and releases. Added optional `search_self_serve_*` browser telemetry markers, route-aware fixture coverage, and a dedicated Playwright operator-journey suite (`unified-search-priority-route-journeys.e2e.spec.ts`). Focused Angular coverage passed `37/37`; the combined search Playwright pack passed `22/22`. | Developer / Test Automation | ## Decisions & Risks - Decision: rollout should prioritize high-frequency operator pages before broad route coverage. @@ -121,6 +122,9 @@ Completion criteria: - Mitigation: require Playwright coverage for each high-value journey. - Risk: live-ingested routes can drift from mocked expectations as ingestion adapters evolve. - Mitigation: document route parity and keep at least one live route in the regular regression pack. +- Decision: self-serve gap telemetry remains optional and client-side; it must never block or alter search behavior when telemetry is disabled. +- Risk: shared-worktree frontend errors from unrelated modules can interfere with pointer-based E2E interactions on affected routes. +- Mitigation: keep search rollout assertions scoped to the search surface and avoid changing unrelated feature files owned by other agents. ## Next Checkpoints - 2026-03-12: Priority page rollout started after answer contract freeze. diff --git a/docs/implplan/SPRINT_20260307_020_FE_overflow_answer_consumption_and_chip_viability.md b/docs-archived/implplan/SPRINT_20260307_020_FE_overflow_answer_consumption_and_chip_viability.md similarity index 100% rename from docs/implplan/SPRINT_20260307_020_FE_overflow_answer_consumption_and_chip_viability.md rename to docs-archived/implplan/SPRINT_20260307_020_FE_overflow_answer_consumption_and_chip_viability.md diff --git a/docs/modules/ui/architecture.md b/docs/modules/ui/architecture.md index 22b2579ee..47ea22d9f 100644 --- a/docs/modules/ui/architecture.md +++ b/docs/modules/ui/architecture.md @@ -189,23 +189,21 @@ Each feature folder builds as a **standalone route** (lazy loaded). All HTTP sha ### 3.13 Global Search and Assistant Bridge -* **Search -> assistant handoff**: result cards and synthesis panel expose `Ask AI` actions that route to `/security/triage?openChat=true` and seed chat context through `SearchChatContextService`. -* **Assistant host**: `/security/triage` mounts `SecurityTriageChatHostComponent`, which consumes `openChat` intent deterministically and opens the chat drawer in the primary shell. -* **Assistant -> search return**: assistant responses expose `Search for more` and `Search related` actions; these populate global search query/domain context and focus the search surface. -* **Guided discovery empty state**: when global search is focused with an empty query, the panel renders an 8-domain guide (findings, VEX, policy, docs, API, health, operations, timeline), contextual suggestion chips, and quick actions (`Getting Started`, `Run Health Check`, `View Recent Scans`). -* **Automatic page-open suggestions**: `AmbientContextService` tracks router navigation and updates global-search suggestion chips/placeholders automatically for every opened page without requiring manual refresh. -* **Context rail**: empty-state search also renders a compact rail showing the current page title/description, active domain scope, and last meaningful action on the same page scope when available. -* **Last-action follow-up suggestions**: the same service keeps a per-route scoped last action (search result open/action, Ask AI handoff, chat return actions) with deterministic TTL bounds; empty-state chips prepend a contextual `follow up: ...` suggestion when available. -* **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. -* **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`. +* **Search-first shell**: the top-bar search field is the primary operator entry. AdvisoryAI is opened from a compact secondary icon beside search or from grounded answer/result actions, not as a competing route-first workflow. +* **Shell-level assistant drawer**: deeper-help opens in a global drawer and keeps the operator on the current page route; focus is restored back to search when the drawer closes. +* **Assistant -> search return**: assistant responses can return the user back into global search with populated query context and deterministic `chat_*` action metadata. +* **Zero-learning empty state**: focused empty-state search renders only current-page context, successful history, and executable starter chips/questions. Domain-teaching cards, scope toggles, and recovery panels are intentionally absent from the primary flow. +* **Automatic page-open suggestions**: `AmbientContextService` tracks router navigation and updates starter chips/placeholders automatically for every opened page without requiring manual refresh. +* **Context rail**: empty-state search renders the current page title plus compact tokens for scope and last meaningful action on the same page scope when available. +* **Last-action follow-up suggestions**: the same service keeps a per-route scoped recent-action history with deterministic TTL bounds; surfaced starters can prepend `follow up: ...` chips when that improves relevance. +* **Implicit route weighting**: global search always prefers current-page evidence first and renders cross-scope overflow as a quiet secondary section only when it materially improves the answer. +* **Answer-first search**: every non-empty search renders a visible answer panel before raw cards; the panel resolves to `grounded`, `clarify`, or `insufficient` and never leaves the operator with a 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 starter questions and answer states reuse them as follow-up or clarification buttons. +* **Priority route rollout**: mocked end-to-end journeys explicitly cover findings, policy, doctor, timeline, and release-control routes; live ingestion-backed route verification remains required where corpus parity already exists. +* **Suggestion executability gate**: contextual/page starters preflight through backend viability signals before render so dead suggestions are suppressed instead of being taught to the user. +* **Ambient payload activation**: each global search request sends ambient context (`currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, optional `lastAction`) so AdvisoryAI can apply contextual ranking and answer shaping. +* **Contract governance**: contextual chips follow `docs/modules/ui/search-chip-context-contract.md`, while self-serve questions, rollout ownership, and fallback states follow `docs/modules/ui/search-self-serve-contract.md`; both are implemented in `search-context.registry.ts`. +* **Optional telemetry markers**: global search may emit client-side `search_self_serve_*` markers (`gap`, `reformulation`, `recovery`, `suggestion_suppressed`) for backlog review, but search behavior must remain unchanged when telemetry is disabled or sinks fail. * **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/search-self-serve-contract.md b/docs/modules/ui/search-self-serve-contract.md index df40fac26..c468cf1e9 100644 --- a/docs/modules/ui/search-self-serve-contract.md +++ b/docs/modules/ui/search-self-serve-contract.md @@ -20,6 +20,22 @@ - concise, operator-facing wording - no tenant secrets or volatile IDs in fallback copy +## Priority rollout ownership +| Context ID | Route family | Page ownership rule | Verification floor | +| --- | --- | --- | --- | +| `findings` | `/security/triage`, `/security/findings` | Security triage pages own the findings questions and clarifiers. | Mocked Playwright journey + shared search regressions | +| `policy` | `/ops/policy` | Policy workspace owns the questions and recovery prompts for rules, gates, and exceptions. | Mocked Playwright journey + shared search regressions | +| `doctor` | `/ops/operations/doctor`, `/ops/operations/system-health` | Doctor pages own the health-check questions and release-readiness follow-ups. | Mocked Playwright journey + live Doctor route pack | +| `timeline` | `/ops/timeline`, `/audit`, `/evidence/audit-log` | Timeline/Audit pages own time-window clarifiers and causal follow-ups. | Mocked Playwright journey | +| `releases` | `/releases`, `/mission-control` | Release-control pages own approval/blocker/next-step questions. | Mocked Playwright journey | + +## Page team checklist +1. Add or update the route entry in `SEARCH_CONTEXT_DEFINITIONS`. +2. Define bounded `commonQuestions[]` and `clarifyingQuestions[]`. +3. Keep copy operator-facing; do not expose Stella internals or scoring jargon. +4. Verify the page still works with telemetry disabled or OTLP unset. +5. Add/update unit coverage for route-specific questions and a Playwright journey for the page family. + ## Answer-first UX contract - Every non-empty search must render one visible answer state before raw results: - `grounded` @@ -40,6 +56,22 @@ - `clarifyingQuestions[]` when no grounded answer exists - "Related searches" remains driven by contextual chip logic so pages do not need to define a second parallel action system. +## Optional telemetry hooks +- Search may emit optional client-side telemetry markers for backlog triage: + - `search_self_serve_gap` + - `search_self_serve_reformulation` + - `search_self_serve_recovery` + - `search_self_serve_suggestion_suppressed` +- These markers are additive only. If telemetry is disabled or the sink is unavailable, search behavior, history, and assistant handoffs must remain unchanged. +- Marker payloads must avoid raw query text. Use route, answer state, counts, and reformulation depth instead. + +## Gap review workflow +1. Review repeated `search_self_serve_gap` and `search_self_serve_reformulation` events by route family. +2. Compare them with visible no-result/clarify journeys from Playwright and operator feedback. +3. Update the page-owned question set or starter chips for the affected route. +4. Add or tighten a Playwright journey before marking the gap closed. +5. Record the adjustment in the active sprint `Execution Log` so the change is traceable. + ## Source of truth - Page registry and interfaces: - `src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts` @@ -55,6 +87,7 @@ - grounded answer - clarify recovery - answer-to-AdvisoryAI handoff + - priority-route rollout journeys (`findings`, `policy`, `doctor`, `timeline`, `releases`) 4. Keep route and API behavior mocked/deterministic; no live network dependencies. ## Relationship to chip contract diff --git a/docs/modules/ui/search-zero-learning-primary-entry.md b/docs/modules/ui/search-zero-learning-primary-entry.md index ea33e588c..f83f19b36 100644 --- a/docs/modules/ui/search-zero-learning-primary-entry.md +++ b/docs/modules/ui/search-zero-learning-primary-entry.md @@ -124,6 +124,8 @@ - Implemented from the operator-correction pass: FE search contracts no longer depend on hidden `Find / Explain / Act` metadata, starter chips wait for backend viability before rendering, `Did you mean` is the first in-panel cue under the search field, and successful recent history now uses a structured `stella-successful-searches-v3` contract that ignores legacy bare-string entries on load. - Implemented from the final correction pass: the primary surface now uses secondary "deeper help/details" assistant language instead of presenting a separate AdvisoryAI product, overflow results read as supporting context, and starter chips that execute to no useful result are suppressed from the current page until context changes. - Implemented from the final live verification pass: the supported live route matrix now covers Doctor, Security triage, Policy governance, and Advisories/VEX with corpus rebuild preflight plus end-to-end suggestion execution on ready routes. +- Implemented from the self-serve rollout pass: mocked operator-journey coverage now explicitly covers findings, policy, doctor, timeline, and releases route families end to end, including grounded, clarify, overflow, and deeper-help handoffs. +- Implemented from the self-serve rollout pass: optional browser telemetry markers now capture low-coverage journeys (`gap`, `reformulation`, `recovery`, `suggestion_suppressed`) without changing search behavior when telemetry is disabled. - Still pending from the corrective phases: an explicit client-side telemetry opt-out control if product needs a visible switch. Current behavior is already failure-tolerant when analytics endpoints or sinks are unavailable. ## Execution phases - operator correction pass 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 51905b711..98b6d8aae 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 @@ -34,6 +34,7 @@ import { AmbientContextService } from '../../core/services/ambient-context.servi import { SearchChatContextService } from '../../core/services/search-chat-context.service'; import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service'; import { I18nService } from '../../core/i18n'; +import { TelemetryClient } from '../../core/telemetry/telemetry.client'; import { normalizeSearchActionRoute } from './search-route-matrix'; type SearchSuggestionView = { @@ -50,6 +51,12 @@ type SearchStarterView = { kind: 'question' | 'suggestion'; }; type SuggestedExecutionSource = 'starter' | 'question' | 'answer-next' | 'did-you-mean'; +type SearchGapJourney = { + route: string; + queryKey: string; + status: 'clarify' | 'insufficient'; + reformulationCount: number; +}; type SearchContextPanelView = { title: string; description: string; @@ -923,6 +930,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { private readonly searchChatContext = inject(SearchChatContextService); private readonly assistantDrawer = inject(SearchAssistantDrawerService); private readonly i18n = inject(I18nService); + private readonly telemetry = inject(TelemetryClient); private readonly destroy$ = new Subject(); private readonly searchTerms$ = new Subject(); private readonly recentSearchStorageKey = 'stella-successful-searches-v3'; @@ -932,6 +940,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { | { query: string; source: SuggestedExecutionSource } | null = null; private wasDegradedMode = false; + private activeGapJourney: SearchGapJourney | null = null; private escapeCount = 0; private placeholderRotationHandle: ReturnType | null = null; private blurHideHandle: ReturnType | null = null; @@ -1306,6 +1315,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { ) .subscribe(() => { this.consumeChatToSearchContext(); + this.activeGapJourney = null; this.clearSuppressedStarterQueries(); if (this.isFocused()) { this.refreshSuggestionViability(); @@ -1368,6 +1378,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { ); } this.reconcileSuggestedExecution(response); + this.emitSelfServeGapTelemetry(response); // Sprint 106 / G6: Emit search analytics events this.emitSearchAnalytics(response); @@ -2038,6 +2049,96 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { }); } + private emitSelfServeGapTelemetry(response: UnifiedSearchResponse): void { + const query = response.query.trim(); + if (!query) { + return; + } + + const route = this.router.url; + const queryKey = this.normalizeQueryKey(query); + const status = this.inferAnswerStatus(response); + const queryTokenCount = query.split(/\s+/).filter((token) => token.length > 0).length; + const coverage = response.coverage; + const currentScopeDomain = coverage?.currentScopeDomain ?? null; + const currentScopeCoverage = coverage?.domains.find((domain) => domain.isCurrentScope) + ?? coverage?.domains.find((domain) => domain.domain === currentScopeDomain) + ?? null; + + if (status === 'grounded') { + if (this.activeGapJourney && this.activeGapJourney.route === route) { + this.telemetry.emit('search_self_serve_recovery', { + route, + previousStatus: this.activeGapJourney.status, + reformulationCount: this.activeGapJourney.reformulationCount, + queryLength: query.length, + queryTokenCount, + cardCount: response.cards.length, + overflowCount: response.overflow?.cards.length ?? 0, + currentScopeDomain, + }); + } + + this.activeGapJourney = null; + return; + } + + let reformulationCount = 0; + if (this.activeGapJourney && this.activeGapJourney.route === route) { + reformulationCount = this.activeGapJourney.reformulationCount; + if (this.activeGapJourney.queryKey !== queryKey) { + reformulationCount += 1; + this.telemetry.emit('search_self_serve_reformulation', { + route, + previousStatus: this.activeGapJourney.status, + nextStatus: status, + reformulationCount, + queryLength: query.length, + queryTokenCount, + }); + } + } + + this.telemetry.emit('search_self_serve_gap', { + route, + answerStatus: status, + reformulationCount, + queryLength: query.length, + queryTokenCount, + currentScopeDomain, + currentScopeWeighted: coverage?.currentScopeWeighted === true, + currentScopeCandidateCount: currentScopeCoverage?.candidateCount ?? 0, + currentScopeVisibleCount: currentScopeCoverage?.visibleCardCount ?? 0, + cardCount: response.cards.length, + overflowCount: response.overflow?.cards.length ?? 0, + questionCount: status === 'clarify' + ? this.clarifyingQuestions().length + : this.commonQuestions().length, + viableSuggestionCount: this.contextualSuggestions().length, + }); + + this.activeGapJourney = { + route, + queryKey, + status, + reformulationCount, + }; + } + + private inferAnswerStatus(response: UnifiedSearchResponse): SearchAnswerView['status'] { + if (response.contextAnswer) { + return response.contextAnswer.status; + } + + if (this.hasSearchEvidence(response)) { + return 'grounded'; + } + + return this.clarifyingQuestions().length > 0 + ? 'clarify' + : 'insufficient'; + } + private buildGroundedAnswerSummary(response: UnifiedSearchResponse): string { const synthesisSummary = response.synthesis?.summary?.trim(); if (synthesisSummary) { @@ -2243,6 +2344,13 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { if (!this.hasSearchEvidence(response)) { this.suppressStarterQuery(response.query); + this.telemetry.emit('search_self_serve_suggestion_suppressed', { + route: this.router.url, + source: pending.source, + answerStatus: this.inferAnswerStatus(response), + queryLength: response.query.trim().length, + queryTokenCount: response.query.split(/\s+/).filter((token) => token.length > 0).length, + }); } this.pendingSuggestedExecution = null; 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 fc77c9520..98b2888e5 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 @@ -202,4 +202,38 @@ describe('AmbientContextService', () => { 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?'); }); + + it('returns timeline self-serve questions after navigating to the timeline route', () => { + const service = TestBed.inject(AmbientContextService); + + router.url = '/ops/timeline'; + events.next(new NavigationEnd(1, '/ops/timeline', '/ops/timeline')); + + expect(service.currentDomain()).toBe('timeline'); + const commonQuestions = service.getCommonQuestions().map((item) => item.fallback); + const clarifyingQuestions = service.getClarifyingQuestions().map((item) => item.fallback); + + expect(commonQuestions).toContain('What changed before this incident?'); + expect(commonQuestions).toContain('Which release introduced this risk?'); + expect(clarifyingQuestions).toContain('Which deployment, incident, or time window should I narrow this to?'); + expect(clarifyingQuestions).toContain('Do you want causes, impacts, or follow-up events?'); + }); + + it('returns release-control self-serve questions for release routes without forcing a search domain', () => { + const service = TestBed.inject(AmbientContextService); + + router.url = '/releases/deployments'; + events.next(new NavigationEnd(1, '/releases/deployments', '/releases/deployments')); + + expect(service.currentDomain()).toBeNull(); + const commonQuestions = service.getCommonQuestions().map((item) => item.fallback); + const clarifyingQuestions = service.getClarifyingQuestions().map((item) => item.fallback); + const panel = service.getSearchContextPanel(); + + expect(panel?.titleFallback).toBe('Release control'); + expect(commonQuestions).toContain('What blocked this promotion?'); + expect(commonQuestions).toContain('Which approvals are missing?'); + expect(clarifyingQuestions).toContain('Which environment or release should I narrow this to?'); + expect(clarifyingQuestions).toContain('Do you want blockers, approvals, or policy 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 7a5745ce3..03db6e97a 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 @@ -7,6 +7,7 @@ import { UnifiedSearchClient } from '../../app/core/api/unified-search.client'; import { AmbientContextService } from '../../app/core/services/ambient-context.service'; import { SearchAssistantDrawerService } from '../../app/core/services/search-assistant-drawer.service'; import { SearchChatContextService } from '../../app/core/services/search-chat-context.service'; +import { TelemetryClient } from '../../app/core/telemetry/telemetry.client'; import { I18nService } from '../../app/core/i18n'; import { GlobalSearchComponent } from '../../app/layout/global-search/global-search.component'; @@ -19,6 +20,7 @@ describe('GlobalSearchComponent', () => { let router: { url: string; events: Subject; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy }; let searchChatContext: jasmine.SpyObj; let assistantDrawer: jasmine.SpyObj; + let telemetry: jasmine.SpyObj; beforeEach(async () => { localStorage.clear(); @@ -182,6 +184,7 @@ describe('GlobalSearchComponent', () => { 'open', 'close', ]) as jasmine.SpyObj; + telemetry = jasmine.createSpyObj('TelemetryClient', ['emit']) as jasmine.SpyObj; await TestBed.configureTestingModule({ imports: [GlobalSearchComponent], @@ -197,6 +200,7 @@ describe('GlobalSearchComponent', () => { }, { provide: SearchChatContextService, useValue: searchChatContext }, { provide: SearchAssistantDrawerService, useValue: assistantDrawer }, + { provide: TelemetryClient, useValue: telemetry }, ], }).compileComponents(); @@ -610,6 +614,153 @@ describe('GlobalSearchComponent', () => { expect(starterButtons).not.toContain('How do I deploy?'); }); + it('emits optional self-serve gap, reformulation, and recovery telemetry markers', async () => { + searchClient.search.and.returnValues( + of({ + query: 'mystery issue', + topK: 10, + cards: [], + synthesis: null, + coverage: { + currentScopeDomain: 'findings', + currentScopeWeighted: true, + domains: [ + { + domain: 'findings', + candidateCount: 0, + visibleCardCount: 0, + topScore: 0, + isCurrentScope: true, + hasVisibleResults: false, + }, + ], + }, + diagnostics: { + ftsMatches: 0, + vectorMatches: 0, + entityCardCount: 0, + durationMs: 2, + usedVector: false, + mode: 'fts-only', + }, + }), + of({ + query: 'still unclear', + topK: 10, + cards: [], + synthesis: null, + coverage: { + currentScopeDomain: 'findings', + currentScopeWeighted: true, + domains: [ + { + domain: 'findings', + candidateCount: 0, + visibleCardCount: 0, + topScore: 0, + isCurrentScope: true, + hasVisibleResults: false, + }, + ], + }, + diagnostics: { + ftsMatches: 0, + vectorMatches: 0, + entityCardCount: 0, + durationMs: 2, + usedVector: false, + mode: 'fts-only', + }, + }), + of({ + query: 'critical findings', + topK: 10, + cards: [createCard('findings', '/triage/findings/fnd-recovery')], + synthesis: null, + diagnostics: { + ftsMatches: 1, + vectorMatches: 0, + entityCardCount: 1, + durationMs: 2, + usedVector: false, + mode: 'fts-only', + }, + }), + ); + + component.onFocus(); + component.onQueryChange('mystery issue'); + await waitForDebounce(); + + component.onQueryChange('still unclear'); + await waitForDebounce(); + + component.onQueryChange('critical findings'); + await waitForDebounce(); + + expect(telemetry.emit).toHaveBeenCalledWith('search_self_serve_gap', jasmine.objectContaining({ + route: '/security/triage', + answerStatus: 'clarify', + currentScopeDomain: 'findings', + })); + expect(telemetry.emit).toHaveBeenCalledWith('search_self_serve_reformulation', jasmine.objectContaining({ + route: '/security/triage', + previousStatus: 'clarify', + nextStatus: 'clarify', + reformulationCount: 1, + })); + expect(telemetry.emit).toHaveBeenCalledWith('search_self_serve_recovery', jasmine.objectContaining({ + route: '/security/triage', + previousStatus: 'clarify', + reformulationCount: 1, + cardCount: 1, + })); + }); + + it('emits suggestion suppression telemetry when a suggested query dead-ends', async () => { + searchClient.search.and.returnValues( + of({ + query: 'How do I deploy?', + topK: 10, + cards: [], + synthesis: null, + diagnostics: { + ftsMatches: 0, + vectorMatches: 0, + entityCardCount: 0, + durationMs: 2, + usedVector: false, + mode: 'fts-only', + }, + }), + of({ + query: '', + topK: 10, + cards: [], + synthesis: null, + diagnostics: { + ftsMatches: 0, + vectorMatches: 0, + entityCardCount: 0, + durationMs: 0, + usedVector: false, + mode: 'fts-only', + }, + }), + ); + + component.onFocus(); + fixture.detectChanges(); + component.applyStarterQuery({ query: 'How do I deploy?', kind: 'suggestion' }); + await waitForDebounce(); + + expect(telemetry.emit).toHaveBeenCalledWith('search_self_serve_suggestion_suppressed', jasmine.objectContaining({ + route: '/security/triage', + source: 'starter', + answerStatus: 'clarify', + })); + }); + it('suppresses contextual chips marked non-viable by backend suggestion evaluation', () => { searchClient.evaluateSuggestions.and.returnValue(of({ suggestions: [ diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts index 47516da67..f8916d0b2 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts @@ -113,7 +113,7 @@ test.describe('Unified Search - Contextual Suggestions', () => { await waitForEntityCards(page, 1); await page.locator('app-entity-card').first().click(); - await expect(page).toHaveURL(/\/security\/triage\?q=CVE-2024-21626/i); + await expect(page).toHaveURL(/\/security\/triage\?.*q=CVE-2024-21626/i); const searchInput = page.locator('app-global-search input[type="text"]'); await searchInput.focus(); diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts index b55a86971..be54ef6fd 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts @@ -3,7 +3,7 @@ // Shared mock data & helpers for all unified search e2e test suites. // ----------------------------------------------------------------------------- -import type { Page } from '@playwright/test'; +import type { Page, Route } from '@playwright/test'; import { policyAuthorSession } from '../../src/app/testing'; @@ -71,6 +71,21 @@ export const shellSession = { // --------------------------------------------------------------------------- export async function setupBasicMocks(page: Page) { + const jsonStubUnlessDocument = (defaultGetBody: unknown = []): ((route: Route) => Promise) => { + return async (route) => { + if (route.request().resourceType() === 'document') { + await route.fallback(); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(route.request().method() === 'GET' ? defaultGetBody : {}), + }); + }; + }; + page.on('console', (message) => { if (message.type() !== 'error') return; const text = message.text(); @@ -115,9 +130,20 @@ export async function setupBasicMocks(page: Page) { return route.fulfill({ status: 400, body: 'blocked' }); }); + await page.route('**/api/**', jsonStubUnlessDocument()); + await page.route('**/gateway/**', jsonStubUnlessDocument()); + await page.route('**/policy/**', jsonStubUnlessDocument()); + await page.route('**/scanner/**', jsonStubUnlessDocument()); + await page.route('**/concelier/**', jsonStubUnlessDocument()); + await page.route('**/attestor/**', jsonStubUnlessDocument()); + await page.route('**/api/v1/search/suggestions/evaluate', async (route) => { - const body = (route.request().postDataJSON() as { queries?: string[] } | null) ?? {}; + const body = (route.request().postDataJSON() as { + queries?: string[]; + ambient?: { currentRoute?: string }; + } | null) ?? {}; const queries = body.queries ?? []; + const currentScopeDomain = resolveMockScopeDomain(body.ambient?.currentRoute); return route.fulfill({ status: 200, contentType: 'application/json', @@ -128,15 +154,15 @@ export async function setupBasicMocks(page: Page) { status: 'grounded', code: 'retrieved_scope_weighted_evidence', cardCount: 1, - leadingDomain: 'findings', + leadingDomain: currentScopeDomain, reason: 'Evidence is available for this suggestion.', })), coverage: { - currentScopeDomain: 'findings', + currentScopeDomain, currentScopeWeighted: true, domains: [ { - domain: 'findings', + domain: currentScopeDomain, candidateCount: Math.max(1, queries.length), visibleCardCount: Math.max(1, queries.length), topScore: 0.9, @@ -509,6 +535,68 @@ export function policyCard(opts: { }; } +export function timelineCard(opts: { + eventId: string; + title: string; + snippet: string; + score?: number; +}): CardFixture { + return { + entityKey: `timeline:${opts.eventId}`, + entityType: 'ops_event', + domain: 'timeline', + title: opts.title, + snippet: opts.snippet, + score: opts.score ?? 0.81, + actions: [ + { + label: 'Open event', + actionType: 'navigate', + route: `/ops/timeline/${opts.eventId}`, + isPrimary: true, + }, + { + label: 'Open audit trail', + actionType: 'navigate', + route: `/evidence/audit-log/events/${opts.eventId}`, + isPrimary: false, + }, + ], + sources: ['timeline'], + }; +} + +export function releaseCard(opts: { + releaseId: string; + title: string; + snippet: string; + score?: number; +}): CardFixture { + return { + entityKey: `release:${opts.releaseId}`, + entityType: 'platform_entity', + domain: 'platform', + title: opts.title, + snippet: opts.snippet, + score: opts.score ?? 0.84, + actions: [ + { + label: 'Open release', + actionType: 'navigate', + route: `/releases/${opts.releaseId}`, + isPrimary: true, + }, + { + label: 'Open approvals', + actionType: 'navigate', + route: '/releases/approvals', + isPrimary: false, + }, + ], + sources: ['platform'], + }; +} + export function docsCard(opts: { docPath: string; title: string; @@ -550,3 +638,45 @@ export function apiCard(opts: { sources: ['knowledge'], }; } + +function resolveMockScopeDomain(currentRoute?: string): string { + const normalizedRoute = (currentRoute ?? '').trim().toLowerCase(); + + if (normalizedRoute.includes('/ops/policy/vex') + || normalizedRoute.includes('/security/advisories-vex') + || normalizedRoute.includes('/vex-hub')) { + return 'vex'; + } + + if (normalizedRoute.includes('/ops/policy')) { + return 'policy'; + } + + if (normalizedRoute.includes('/ops/operations/doctor') + || normalizedRoute.includes('/ops/operations/system-health')) { + return 'knowledge'; + } + + if (normalizedRoute.includes('/ops/timeline') + || normalizedRoute.includes('/evidence/audit-log') + || normalizedRoute.includes('/audit')) { + return 'timeline'; + } + + if (normalizedRoute.includes('/releases') + || normalizedRoute.includes('/mission-control')) { + return 'platform'; + } + + if (normalizedRoute.includes('/ops/graph') + || normalizedRoute.includes('/security/reach')) { + return 'graph'; + } + + if (normalizedRoute.includes('/ops/operations/jobs') + || normalizedRoute.includes('/ops/operations/scheduler')) { + return 'ops_memory'; + } + + return 'findings'; +} diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-priority-route-journeys.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-priority-route-journeys.e2e.spec.ts new file mode 100644 index 000000000..fb1239067 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-priority-route-journeys.e2e.spec.ts @@ -0,0 +1,701 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { + buildResponse, + emptyResponse, + findingCard, + policyCard, + releaseCard, + setupAuthenticatedSession, + setupBasicMocks, + timelineCard, + typeInSearch, + waitForEntityCards, + waitForResults, +} from './unified-search-fixtures'; + +const doctorConnectivityCard = { + entityKey: 'doctor:check.core.db.connectivity', + entityType: 'doctor', + domain: 'knowledge', + title: 'PostgreSQL connectivity', + snippet: 'Database connectivity is degraded and blocks the current release readiness check.', + score: 0.95, + actions: [ + { + label: 'Open check', + actionType: 'navigate', + route: '/ops/operations/doctor?check=check.core.db.connectivity', + isPrimary: true, + }, + ], + sources: ['knowledge'], + metadata: { + checkId: 'check.core.db.connectivity', + }, +}; + +const findingsGroundedResponse = buildResponse( + 'What evidence blocks this release?', + [ + findingCard({ + cveId: 'CVE-2024-21626', + title: 'CVE-2024-21626 in api-gateway', + snippet: 'A reachable critical vulnerability remains unresolved in the active production workload.', + severity: 'critical', + }), + ], + undefined, + { + contextAnswer: { + status: 'grounded', + code: 'retrieved_scope_weighted_evidence', + summary: 'A reachable critical finding in api-gateway is the strongest release blocker on this page.', + reason: 'Findings evidence ranked ahead of related policy matches.', + evidence: 'Grounded in 2 sources across Findings and Policy.', + citations: [ + { + entityKey: 'cve:CVE-2024-21626', + title: 'CVE-2024-21626 in api-gateway', + domain: 'findings', + }, + ], + questions: [ + { + query: 'What is the safest remediation path?', + kind: 'follow_up', + }, + ], + }, + overflow: { + currentScopeDomain: 'findings', + reason: 'Policy impact stays relevant but secondary to the current finding.', + cards: [ + policyCard({ + ruleId: 'POL-118', + title: 'POL-118 release blocker', + snippet: 'Production rollout remains blocked while this finding is unresolved.', + }), + ], + }, + coverage: { + currentScopeDomain: 'findings', + currentScopeWeighted: true, + domains: [ + { + domain: 'findings', + candidateCount: 2, + visibleCardCount: 1, + topScore: 0.95, + isCurrentScope: true, + hasVisibleResults: true, + }, + { + domain: 'policy', + candidateCount: 1, + visibleCardCount: 1, + topScore: 0.73, + isCurrentScope: false, + hasVisibleResults: true, + }, + ], + }, + }, +); + +const policyGroundedResponse = buildResponse( + 'Why is this gate failing?', + [ + policyCard({ + ruleId: 'POL-118', + title: 'POL-118 release blocker', + snippet: 'The gate is failing because reachable critical findings still block production promotion.', + }), + ], + undefined, + { + contextAnswer: { + status: 'grounded', + code: 'retrieved_scope_weighted_evidence', + summary: 'The release gate is failing because production still has reachable critical findings with no approved exception.', + reason: 'Policy evidence matched directly in the current route.', + evidence: 'Grounded in 2 sources across Policy and Findings.', + citations: [ + { + entityKey: 'policy:POL-118', + title: 'POL-118 release blocker', + domain: 'policy', + }, + ], + questions: [ + { + query: 'What findings are impacted by this rule?', + kind: 'follow_up', + }, + ], + }, + coverage: { + currentScopeDomain: 'policy', + currentScopeWeighted: true, + domains: [ + { + domain: 'policy', + candidateCount: 2, + visibleCardCount: 1, + topScore: 0.94, + isCurrentScope: true, + hasVisibleResults: true, + }, + ], + }, + }, +); + +const policyStarterResponse = buildResponse( + 'failing policy gates', + [ + policyCard({ + ruleId: 'POL-118', + title: 'POL-118 release blocker', + snippet: 'Current policy blockers remain concentrated around reachable production findings.', + }), + ], + undefined, + { + contextAnswer: { + status: 'grounded', + code: 'retrieved_scope_weighted_evidence', + summary: 'Failing policy gates remain concentrated in the current policy workspace.', + reason: 'Starter search stayed inside policy scope first.', + evidence: 'Grounded in 1 source across Policy.', + citations: [ + { + entityKey: 'policy:POL-118', + title: 'POL-118 release blocker', + domain: 'policy', + }, + ], + }, + coverage: { + currentScopeDomain: 'policy', + currentScopeWeighted: true, + domains: [ + { + domain: 'policy', + candidateCount: 1, + visibleCardCount: 1, + topScore: 0.9, + isCurrentScope: true, + hasVisibleResults: true, + }, + ], + }, + }, +); + +const doctorGroundedResponse = buildResponse( + 'database connectivity', + [doctorConnectivityCard], + undefined, + { + contextAnswer: { + status: 'grounded', + code: 'retrieved_scope_weighted_evidence', + summary: 'Database connectivity is the highest-signal release readiness blocker in the current Doctor view.', + reason: 'Doctor evidence ranked first on the current page.', + evidence: 'Grounded in 1 source across Knowledge.', + citations: [ + { + entityKey: 'doctor:check.core.db.connectivity', + title: 'PostgreSQL connectivity', + domain: 'knowledge', + route: '/ops/operations/doctor?check=check.core.db.connectivity', + }, + ], + }, + coverage: { + currentScopeDomain: 'knowledge', + currentScopeWeighted: true, + domains: [ + { + domain: 'knowledge', + candidateCount: 2, + visibleCardCount: 1, + topScore: 0.95, + isCurrentScope: true, + hasVisibleResults: true, + }, + ], + }, + }, +); + +const timelineGroundedResponse = buildResponse( + 'Which deployment, incident, or time window should I narrow this to?', + [ + timelineCard({ + eventId: 'incident-db-spike', + title: 'Database latency spike before promotion', + snippet: 'A deployment preceded the incident and introduced the latency spike that now blocks the rollout.', + }), + ], + undefined, + { + contextAnswer: { + status: 'grounded', + code: 'retrieved_scope_weighted_evidence', + summary: 'The timeline shows a deployment immediately before the incident that introduced the blocking latency spike.', + reason: 'Timeline evidence became grounded once the time window was narrowed.', + evidence: 'Grounded in 1 source across Timeline.', + citations: [ + { + entityKey: 'timeline:incident-db-spike', + title: 'Database latency spike before promotion', + domain: 'timeline', + }, + ], + }, + coverage: { + currentScopeDomain: 'timeline', + currentScopeWeighted: true, + domains: [ + { + domain: 'timeline', + candidateCount: 1, + visibleCardCount: 1, + topScore: 0.9, + isCurrentScope: true, + hasVisibleResults: true, + }, + ], + }, + }, +); + +const releasesGroundedResponse = buildResponse( + 'What blocked this promotion?', + [ + releaseCard({ + releaseId: 'rel-2026-03-08', + title: 'Release 2026.03.08', + snippet: 'Promotion is blocked by missing production approvals and a policy gate still tied to reachable findings.', + }), + ], + undefined, + { + contextAnswer: { + status: 'grounded', + code: 'retrieved_scope_weighted_evidence', + summary: 'The promotion is blocked by missing approvals first, with a related policy gate also still open.', + reason: 'Release-control evidence outranked broader platform matches.', + evidence: 'Grounded in 2 sources across Platform and Policy.', + citations: [ + { + entityKey: 'release:rel-2026-03-08', + title: 'Release 2026.03.08', + domain: 'platform', + route: '/releases/rel-2026-03-08', + }, + ], + }, + overflow: { + currentScopeDomain: 'platform', + reason: 'A related policy blocker is still relevant but secondary to the current release page.', + cards: [ + policyCard({ + ruleId: 'POL-118', + title: 'POL-118 release blocker', + snippet: 'The same release remains tied to a critical finding with no approved exception.', + }), + ], + }, + coverage: { + currentScopeDomain: 'platform', + currentScopeWeighted: true, + domains: [ + { + domain: 'platform', + candidateCount: 2, + visibleCardCount: 1, + topScore: 0.91, + isCurrentScope: true, + hasVisibleResults: true, + }, + { + domain: 'policy', + candidateCount: 1, + visibleCardCount: 1, + topScore: 0.72, + isCurrentScope: false, + hasVisibleResults: true, + }, + ], + }, + }, +); + +type PriorityRouteCheck = { + path: string; + contextTitle: RegExp; + starterText: RegExp; +}; + +const priorityRoutes: readonly PriorityRouteCheck[] = [ + { + path: '/security/triage', + contextTitle: /findings triage/i, + starterText: /why is this exploitable in my environment\?/i, + }, + { + path: '/ops/policy', + contextTitle: /policy workspace/i, + starterText: /why is this gate failing\?/i, + }, + { + path: '/ops/operations/doctor', + contextTitle: /doctor diagnostics/i, + starterText: /which failing check is blocking release\?/i, + }, + { + path: '/ops/timeline', + contextTitle: /timeline analysis/i, + starterText: /what changed before this incident\?/i, + }, + { + path: '/releases/deployments', + contextTitle: /release control/i, + starterText: /what blocked this promotion\?/i, + }, +] as const; + +test.describe('Unified Search - Priority Route Self-Serve Journeys', () => { + test.beforeEach(async ({ page }) => { + await setupBasicMocks(page); + await setupAuthenticatedSession(page); + await setupDoctorPageMocks(page); + }); + + test('surfaces route-owned starter queries across all priority routes', async ({ page }) => { + await mockPriorityRouteSearch(page); + + for (const route of priorityRoutes) { + await openRoute(page, route.path); + const searchInput = page.locator('app-global-search input[type="text"]'); + await searchInput.focus(); + await waitForResults(page); + + await expect(page.locator('.search__context-title')).toContainText(route.contextTitle); + await expect(page.locator('[data-starter-kind]', { + hasText: route.starterText, + }).first()).toBeVisible(); + } + }); + + test('opens deeper help from a grounded findings answer without leaving the page route', async ({ page }) => { + const capturedTurnBodies: Array> = []; + await mockPriorityRouteSearch(page); + await mockChatConversation(page, capturedTurnBodies); + + await openRoute(page, '/security/triage'); + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + + const blockerQuestion = page.getByRole('button', { name: 'What evidence blocks this release?' }); + await blockerQuestion.evaluate((element: HTMLElement) => element.click()); + await waitForResults(page); + await waitForEntityCards(page, 1); + + await expect(page.locator('[data-answer-status="grounded"]')).toContainText(/strongest release blocker/i); + await page.locator('[data-answer-action="ask-ai"]').click(); + + await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 }); + await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0); + expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/what evidence blocks this release\?/i); + expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/findings triage/i); + await expect(page).toHaveURL(/\/security\/triage(\?.*)?$/i); + }); + + test('promotes a follow-up chip after opening a grounded doctor result', async ({ page }) => { + await mockPriorityRouteSearch(page); + + await openRoute(page, '/ops/operations/doctor'); + const searchInput = page.locator('app-global-search input[type="text"]'); + await searchInput.focus(); + await waitForResults(page); + + await page.getByRole('button', { name: 'database connectivity' }).click(); + await waitForResults(page); + await waitForEntityCards(page, 1); + + await page.locator('app-entity-card').first().click(); + await expect(page).toHaveURL(/\/ops\/operations\/doctor\?check=check\.core\.db\.connectivity/i); + + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + await expect(page.locator('.search__suggestions .search__chip', { + hasText: /follow up:\s*database connectivity/i, + }).first()).toBeVisible(); + }); + + test('recovers a timeline search from clarify to grounded with the route-owned narrowing question', async ({ page }) => { + await mockPriorityRouteSearch(page); + + await openRoute(page, '/ops/timeline'); + await typeInSearch(page, 'spike'); + await waitForResults(page); + + await expect(page.locator('[data-answer-status="clarify"]')).toBeVisible(); + await page.getByRole('button', { name: 'Which deployment, incident, or time window 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 deployment, incident, or time window should I narrow this to?', + ); + await expect(page.locator('[data-answer-status="grounded"]')).toContainText(/deployment immediately before the incident/i); + }); + + test('reruns a policy next-search inside the current policy route context', async ({ page }) => { + const capturedRequests: Array> = []; + await mockPriorityRouteSearch(page, capturedRequests); + + await openRoute(page, '/ops/policy'); + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + + await page.getByRole('button', { name: 'Why is this gate failing?' }).click(); + await waitForResults(page); + await waitForEntityCards(page, 1); + + const nextSearchButton = page.locator('[data-answer-next-search]').filter({ + hasNotText: /follow up:/i, + }).first(); + await expect(nextSearchButton).toBeVisible(); + await nextSearchButton.click(); + await waitForResults(page); + await waitForEntityCards(page, 1); + + const rerunQuery = await page.locator('app-global-search input[type="text"]').inputValue(); + expect(rerunQuery.trim().length).toBeGreaterThan(0); + await expect.poll(() => + capturedRequests.some((request) => String(request['q'] ?? '') === rerunQuery), + ).toBe(true); + await expect(page).toHaveURL(/\/ops\/policy/i); + }); + + test('shows overflow as a secondary section for grounded release-control answers', async ({ page }) => { + await mockPriorityRouteSearch(page); + + await openRoute(page, '/releases/deployments'); + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + + await page.getByRole('button', { name: 'What blocked this promotion?' }).click(); + await waitForResults(page); + await waitForEntityCards(page, 1); + + await expect(page.locator('[data-answer-status="grounded"]')).toContainText(/missing approvals first/i); + await expect(page.locator('[data-overflow-results]')).toContainText(/also relevant elsewhere/i); + await expect(page.locator('[data-overflow-results]')).toContainText(/related policy blocker is still relevant/i); + }); +}); + +async function openRoute(page: Page, path: string): Promise { + await page.goto(path); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); +} + +async function mockPriorityRouteSearch( + page: Page, + capturedRequests: Array> = [], +): Promise { + await page.route('**/search/query**', async (route) => { + const body = (route.request().postDataJSON() as Record | null) ?? {}; + capturedRequests.push(body); + + const query = String(body['q'] ?? '').trim().toLowerCase(); + const ambient = body['ambient'] as Record | undefined; + const currentRoute = String(ambient?.['currentRoute'] ?? '').toLowerCase(); + + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(resolvePriorityRouteResponse(currentRoute, query)), + }); + }); +} + +function resolvePriorityRouteResponse(currentRoute: string, query: string): unknown { + if (currentRoute.includes('/security/triage')) { + if (query.includes('what evidence blocks this release')) { + return findingsGroundedResponse; + } + + if (query.includes('what is the safest remediation path')) { + return findingsGroundedResponse; + } + } + + if (currentRoute.includes('/ops/policy')) { + if (query.includes('why is this gate failing')) { + return policyGroundedResponse; + } + + if (query.includes('failing policy gates')) { + return policyStarterResponse; + } + + if (query.includes('policy exceptions') || query.includes('production deny rules')) { + return policyStarterResponse; + } + } + + if (currentRoute.includes('/ops/operations/doctor')) { + if (query.includes('database connectivity')) { + return doctorGroundedResponse; + } + } + + if (currentRoute.includes('/ops/timeline')) { + if (query.includes('which deployment, incident, or time window should i narrow this to')) { + return timelineGroundedResponse; + } + + if (query.includes('spike')) { + return emptyResponse('spike'); + } + } + + if (currentRoute.includes('/releases')) { + if (query.includes('what blocked this promotion')) { + return releasesGroundedResponse; + } + } + + return emptyResponse(query); +} + +async function mockChatConversation( + page: Page, + capturedTurnBodies: Array>, +): 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-priority-routes-1', + tenantId: 'test-tenant', + userId: 'tester', + context: {}, + turns: [], + createdAt: '2026-03-08T00:00:00.000Z', + updatedAt: '2026-03-08T00:00:00.000Z', + }), + }); + }); + + await page.route('**/api/v1/advisory-ai/conversations/*/turns', async (route) => { + if (route.request().method() !== 'POST') { + return route.continue(); + } + + capturedTurnBodies.push((route.request().postDataJSON() as Record | null) ?? {}); + const events = [ + 'event: progress', + 'data: {"stage":"searching"}', + '', + 'event: token', + 'data: {"content":"I can expand the current grounded answer and tell you the safest next step."}', + '', + 'event: done', + 'data: {"turnId":"turn-priority-routes-1","groundingScore":0.94}', + '', + ].join('\n'); + + return route.fulfill({ + status: 200, + headers: { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache', + }, + body: events, + }); + }); +} + +async function setupDoctorPageMocks(page: Page): Promise { + await page.route('**/doctor/api/v1/doctor/plugins**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + plugins: [ + { + pluginId: 'integration.registry', + displayName: 'Registry Integration', + category: 'integration', + version: '1.0.0', + checkCount: 3, + }, + ], + total: 1, + }), + }), + ); + + await page.route('**/doctor/api/v1/doctor/checks**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + checks: [ + { + checkId: 'check.core.db.connectivity', + name: 'Database connectivity', + description: 'Verify PostgreSQL connectivity for release readiness.', + pluginId: 'integration.registry', + category: 'integration', + defaultSeverity: 'fail', + tags: ['database', 'postgresql'], + estimatedDurationMs: 5000, + }, + ], + total: 1, + }), + }), + ); + + await page.route('**/doctor/api/v1/doctor/run', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ runId: 'dr-priority-route-001' }), + }), + ); + + await page.route('**/doctor/api/v1/doctor/run/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runId: 'dr-priority-route-001', + status: 'completed', + startedAt: '2026-03-08T08:00:00Z', + completedAt: '2026-03-08T08:00:06Z', + durationMs: 6000, + summary: { passed: 0, info: 0, warnings: 0, failed: 1, skipped: 0, total: 1 }, + overallSeverity: 'fail', + results: [], + }), + }), + ); +}