diff --git a/devops/compose/README.md b/devops/compose/README.md index 1abbe68cf..f80e89c4a 100644 --- a/devops/compose/README.md +++ b/devops/compose/README.md @@ -147,10 +147,10 @@ Validation endpoints: ```bash # Aggregated OpenAPI -curl -k https://127.1.0.1/openapi.json +curl -k https://stella-ops.local/openapi.json # Timeline API schema (through router-gateway) -curl -k https://127.1.0.1/openapi.json | jq '.paths["/api/v1/timeline"]' +curl -k https://stella-ops.local/openapi.json | jq '.paths["/api/v1/timeline"]' # Header search routing smoke (fails on missing /api/v1/search* or /api/v1/advisory-ai/search* routes) pwsh ./scripts/header-search-smoke.ps1 diff --git a/devops/compose/envsettings-override.json b/devops/compose/envsettings-override.json index af59c2107..41e0faa44 100644 --- a/devops/compose/envsettings-override.json +++ b/devops/compose/envsettings-override.json @@ -14,50 +14,50 @@ "refreshLeewaySeconds": 60 }, "apiBaseUrls": { - "vulnexplorer": "https://127.1.0.1", - "replay": "https://127.1.0.1", - "notify": "https://127.1.0.1", - "notifier": "https://127.1.0.1", - "airgapController": "https://127.1.0.1", - "gateway": "https://127.1.0.1", - "doctor": "https://127.1.0.1", - "taskrunner": "https://127.1.0.1", - "timelineindexer": "https://127.1.0.1", - "timeline": "https://127.1.0.1", - "packsregistry": "https://127.1.0.1", - "findingsLedger": "https://127.1.0.1", - "policyGateway": "https://127.1.0.1", - "registryTokenservice": "https://127.1.0.1", - "graph": "https://127.1.0.1", - "issuerdirectory": "https://127.1.0.1", - "router": "https://127.1.0.1", - "integrations": "https://127.1.0.1", - "platform": "https://127.1.0.1", - "smremote": "https://127.1.0.1", - "signals": "https://127.1.0.1", - "vexlens": "https://127.1.0.1", - "scheduler": "https://127.1.0.1", - "concelier": "https://127.1.0.1", - "opsmemory": "https://127.1.0.1", - "binaryindex": "https://127.1.0.1", - "signer": "https://127.1.0.1", - "reachgraph": "https://127.1.0.1", - "authority": "https://127.1.0.1", - "unknowns": "https://127.1.0.1", - "scanner": "https://127.1.0.1", - "sbomservice": "https://127.1.0.1", - "symbols": "https://127.1.0.1", - "jobengine": "https://127.1.0.1", - "policyEngine": "https://127.1.0.1", - "attestor": "https://127.1.0.1", - "vexhub": "https://127.1.0.1", - "riskengine": "https://127.1.0.1", - "airgapTime": "https://127.1.0.1", - "advisoryai": "https://127.1.0.1", - "excititor": "https://127.1.0.1", - "cartographer": "https://127.1.0.1", - "evidencelocker": "https://127.1.0.1", - "exportcenter": "https://127.1.0.1" + "vulnexplorer": "https://stella-ops.local", + "replay": "https://stella-ops.local", + "notify": "https://stella-ops.local", + "notifier": "https://stella-ops.local", + "airgapController": "https://stella-ops.local", + "gateway": "https://stella-ops.local", + "doctor": "https://stella-ops.local", + "taskrunner": "https://stella-ops.local", + "timelineindexer": "https://stella-ops.local", + "timeline": "https://stella-ops.local", + "packsregistry": "https://stella-ops.local", + "findingsLedger": "https://stella-ops.local", + "policyGateway": "https://stella-ops.local", + "registryTokenservice": "https://stella-ops.local", + "graph": "https://stella-ops.local", + "issuerdirectory": "https://stella-ops.local", + "router": "https://stella-ops.local", + "integrations": "https://stella-ops.local", + "platform": "https://stella-ops.local", + "smremote": "https://stella-ops.local", + "signals": "https://stella-ops.local", + "vexlens": "https://stella-ops.local", + "scheduler": "https://stella-ops.local", + "concelier": "https://stella-ops.local", + "opsmemory": "https://stella-ops.local", + "binaryindex": "https://stella-ops.local", + "signer": "https://stella-ops.local", + "reachgraph": "https://stella-ops.local", + "authority": "https://stella-ops.local", + "unknowns": "https://stella-ops.local", + "scanner": "https://stella-ops.local", + "sbomservice": "https://stella-ops.local", + "symbols": "https://stella-ops.local", + "jobengine": "https://stella-ops.local", + "policyEngine": "https://stella-ops.local", + "attestor": "https://stella-ops.local", + "vexhub": "https://stella-ops.local", + "riskengine": "https://stella-ops.local", + "airgapTime": "https://stella-ops.local", + "advisoryai": "https://stella-ops.local", + "excititor": "https://stella-ops.local", + "cartographer": "https://stella-ops.local", + "evidencelocker": "https://stella-ops.local", + "exportcenter": "https://stella-ops.local" }, "setup": "complete" } diff --git a/devops/compose/perf_microservice.json b/devops/compose/perf_microservice.json index ba8853fd2..79f05ce3e 100644 --- a/devops/compose/perf_microservice.json +++ b/devops/compose/perf_microservice.json @@ -3,7 +3,7 @@ "mode": "microservice", "targets": [ { - "url": "https://127.1.0.1/openapi.json", + "url": "https://stella-ops.local/openapi.json", "samples": 15, "p50Ms": 54.8, "p95Ms": 69.98, @@ -12,7 +12,7 @@ "statusCodes": "200=15" }, { - "url": "https://127.1.0.1/api/v1/timeline/events?limit=1", + "url": "https://stella-ops.local/api/v1/timeline/events?limit=1", "samples": 15, "p50Ms": 18.39, "p95Ms": 33.66, @@ -21,7 +21,7 @@ "statusCodes": "401=15" }, { - "url": "https://127.1.0.1/api/v1/advisory-ai/adapters/llm/providers", + "url": "https://stella-ops.local/api/v1/advisory-ai/adapters/llm/providers", "samples": 15, "p50Ms": 185.37, "p95Ms": 189.69, diff --git a/devops/compose/perf_mode_comparison.json b/devops/compose/perf_mode_comparison.json index 126104302..856e059a2 100644 --- a/devops/compose/perf_mode_comparison.json +++ b/devops/compose/perf_mode_comparison.json @@ -4,7 +4,7 @@ "candidate": "microservice", "deltas": [ { - "url": "https://127.1.0.1/openapi.json", + "url": "https://stella-ops.local/openapi.json", "reverse_p50_ms": 71.57, "micro_p50_ms": 54.8, "delta_p50_ms": -16.77, @@ -15,7 +15,7 @@ "micro_status_codes": "200=15" }, { - "url": "https://127.1.0.1/api/v1/timeline/events?limit=1", + "url": "https://stella-ops.local/api/v1/timeline/events?limit=1", "reverse_p50_ms": 16.51, "micro_p50_ms": 18.39, "delta_p50_ms": 1.88, @@ -26,7 +26,7 @@ "micro_status_codes": "401=15" }, { - "url": "https://127.1.0.1/api/v1/advisory-ai/adapters/llm/providers", + "url": "https://stella-ops.local/api/v1/advisory-ai/adapters/llm/providers", "reverse_p50_ms": 16.03, "micro_p50_ms": 185.37, "delta_p50_ms": 169.34, diff --git a/devops/compose/perf_reverseproxy.json b/devops/compose/perf_reverseproxy.json index d93d8edc2..52ee12fc2 100644 --- a/devops/compose/perf_reverseproxy.json +++ b/devops/compose/perf_reverseproxy.json @@ -3,7 +3,7 @@ "mode": "reverseproxy", "targets": [ { - "url": "https://127.1.0.1/openapi.json", + "url": "https://stella-ops.local/openapi.json", "samples": 15, "p50Ms": 71.57, "p95Ms": 85.53, @@ -12,7 +12,7 @@ "statusCodes": "200=15" }, { - "url": "https://127.1.0.1/api/v1/timeline/events?limit=1", + "url": "https://stella-ops.local/api/v1/timeline/events?limit=1", "samples": 15, "p50Ms": 16.51, "p95Ms": 18.67, @@ -21,7 +21,7 @@ "statusCodes": "401=15" }, { - "url": "https://127.1.0.1/api/v1/advisory-ai/adapters/llm/providers", + "url": "https://stella-ops.local/api/v1/advisory-ai/adapters/llm/providers", "samples": 15, "p50Ms": 16.03, "p95Ms": 17.49, diff --git a/devops/compose/scripts/router-routeprefix-smoke.ps1 b/devops/compose/scripts/router-routeprefix-smoke.ps1 index f055cea15..b817e2cfd 100644 --- a/devops/compose/scripts/router-routeprefix-smoke.ps1 +++ b/devops/compose/scripts/router-routeprefix-smoke.ps1 @@ -1,7 +1,7 @@ param( [string]$RouterConfigPath = "devops/compose/router-gateway-local.json", [string]$OpenApiPath = "devops/compose/openapi_current.json", - [string]$GatewayBaseUrl = "https://127.1.0.1", + [string]$GatewayBaseUrl = "https://stella-ops.local", [ValidateSet("Microservice", "ReverseProxy", "StaticFiles")] [string]$RouteType = "Microservice", [string]$OutputCsv = "devops/compose/openapi_routeprefix_smoke.csv" diff --git a/docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md b/docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md new file mode 100644 index 000000000..bc4910b77 --- /dev/null +++ b/docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md @@ -0,0 +1,170 @@ +# Sprint 20260306-001 - Contextual Search Suggestions (Page + Last Action) + +## Topic & Scope +- Extend global search and AdvisoryAI suggestion behavior so prompts/chips are influenced by the current page and the user's latest meaningful action. +- Activate existing backend ambient-context capabilities end-to-end by sending ambient payloads from the Web client. +- Add deterministic, bounded context-aware refinement logic without breaking offline-first behavior. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: Angular unit tests, AdvisoryAI unit/integration tests, updated module docs, and sprint execution log artifacts. + +## Dependencies & Concurrency +- Upstream contracts and code paths: + - `src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts` + - `src/Web/StellaOps.Web/src/app/core/services/ambient-context.service.ts` + - `src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts` + - `src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchModels.cs` + - `src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Context/AmbientContextProcessor.cs` + - `src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Endpoints/UnifiedSearchEndpoints.cs` +- Cross-module edits are explicitly allowed for this sprint in: + - `src/AdvisoryAI/**` + - `docs/modules/ui/**` + - `docs/modules/advisory-ai/**` +- Safe parallelism: + - FE context-capture and FE rendering tasks can run in parallel with backend DTO expansion once ambient payload shape is frozen. + - Backend ranking/refinement logic should start only after DTO contract freeze. + +## Documentation Prerequisites +- `docs/modules/ui/architecture.md` +- `docs/modules/advisory-ai/unified-search-architecture.md` +- `docs/modules/advisory-ai/knowledge-search.md` +- `docs/modules/ui/implementation_plan.md` +- `docs/modules/advisory-ai/implementation_plan.md` + +## Delivery Tracker + +### WEB-CTX-001 - Baseline behavior and gap mapping +Status: DONE +Dependency: none +Owners: Project Manager, Developer (FE), Developer (AdvisoryAI) +Task description: +- Capture the current behavior matrix for search suggestions and chat-to-search handoff. +- Document concrete gaps discovered in implementation: no ambient payload emitted by Web client, route-only suggestion chips, no "last action" signal, and route-prefix mismatches between FE and AdvisoryAI route-domain mappings. + +Completion criteria: +- [x] Gap matrix is written in sprint notes with file references. +- [x] Confirmed list of required contract changes is frozen before implementation tasks start. + +### WEB-CTX-002 - FE ambient context capture (page + last action) +Status: DONE +Dependency: WEB-CTX-001 +Owners: Developer (FE) +Task description: +- Extend FE context services to track and expose: + - current route + - recent search terms + - visible entity keys (bounded set) + - session id for follow-up search continuity + - last action metadata (surface, action type, domain/entity, timestamp, optional query seed) +- Wire context capture from key UX surfaces: global search result actions, chat "Search for more", chat "Search related", and Ask AI handoff actions. + +Completion criteria: +- [x] Ambient context provider exposes deterministic snapshot API used by search. +- [x] Last action tracking is bounded by TTL and max-history limits. +- [x] Unit tests cover action capture and decay/reset semantics. + +### WEB-CTX-003 - FE -> AdvisoryAI contract activation for ambient payload +Status: DONE +Dependency: WEB-CTX-002 +Owners: Developer (FE), Developer (AdvisoryAI) +Task description: +- Extend `UnifiedSearchClient` request DTO and serialization to include `ambient` object. +- Include route, session, visible entities, recent queries, and last action fields in `/v1/search/query` requests. +- Preserve backward compatibility if backend field support is partially deployed. + +Completion criteria: +- [x] Search requests include ambient payload when context is available. +- [x] Existing search behavior remains functional when ambient payload is absent. +- [x] FE tests assert payload shape sent to `/api/v1/search/query`. + +### WEB-CTX-004 - AdvisoryAI ambient contract and processor extension +Status: TODO +Dependency: WEB-CTX-003 +Owners: Developer (AdvisoryAI) +Task description: +- Extend `AmbientContext`/API DTO contracts to support last action hints. +- Normalize and validate new ambient fields in `UnifiedSearchEndpoints`. +- Update `AmbientContextProcessor` and related flow in `UnifiedSearchService` to apply deterministic boosts/refinements from last action context. +- Align route-domain mapping prefixes with current FE routing patterns. + +Completion criteria: +- [ ] Backend accepts and normalizes last-action ambient metadata. +- [ ] Route mapping parity tests cover FE route prefixes currently in use. +- [ ] Unit/integration tests verify context-aware boosts/refinements are deterministic. + +### WEB-CTX-005 - Context-aware suggestion UX updates +Status: DOING +Dependency: WEB-CTX-004 +Owners: Developer (FE), UX +Task description: +- Update global search empty-state and sparse-result suggestion chips so they blend: + - route-aware defaults + - last-action-aware suggestions + - backend refinements/suggestions when present +- Ensure chat onboarding suggestions remain consistent with shared context rules. + +Completion criteria: +- [ ] Empty-state suggestion chips adapt to route + last action. +- [ ] No-results and sparse-results views surface contextual refinements clearly. +- [ ] Accessibility and keyboard navigation behavior remains intact. + +### WEB-CTX-006 - Quality telemetry and guardrails +Status: TODO +Dependency: WEB-CTX-004 +Owners: Developer (FE), Developer (AdvisoryAI) +Task description: +- Add bounded telemetry markers to evaluate contextual suggestion usefulness without storing sensitive raw prompts beyond existing policy. +- Ensure no unbounded growth in in-memory/session context stores. +- Keep deterministic ordering and offline behavior guarantees. + +Completion criteria: +- [ ] Telemetry and logs distinguish contextual vs non-contextual suggestion paths. +- [ ] Privacy posture for stored query/action metadata is documented and validated. +- [ ] No new external dependencies introduced. + +### WEB-CTX-007 - Docs sync and rollout plan +Status: DOING +Dependency: WEB-CTX-005 +Owners: Documentation author, Project Manager +Task description: +- Update module docs with final contract and behavior: + - `docs/modules/ui/architecture.md` + - `docs/modules/advisory-ai/unified-search-architecture.md` + - `docs/modules/advisory-ai/knowledge-search.md` +- Record rollout strategy (feature flag/canary, fallback behavior, success metrics) and decision log. + +Completion criteria: +- [ ] Docs reflect final API payload and UI behavior. +- [ ] Sprint Decisions & Risks includes rollout gates and fallback plan. +- [ ] Execution log captures implementation and verification evidence links. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-06 | Sprint created from code-and-doc investigation of global search and AdvisoryAI ambient context flow. | Project Manager | +| 2026-03-06 | WEB-CTX-001 marked DONE after baseline gap mapping; WEB-CTX-002 started for FE context capture implementation. | Developer (FE) | +| 2026-03-06 | Implemented FE route + last-action ambient capture, wired chat/search action capture, and emitted ambient payloads from global search requests. | Developer (FE) | +| 2026-03-06 | Verified FE behavior via targeted unit tests: `ambient-context.service.spec.ts`, `global-search.component.spec.ts`, `chat-message.component.spec.ts` (23/23 passing). | Test Automation | +| 2026-03-06 | Added Playwright E2E coverage for contextual suggestions and ambient payload propagation (`tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts`): 3/3 passing. | Test Automation | +| 2026-03-06 | Upgraded suggestion logic to include strategic non-obvious cross-domain prompts per page scope and action-aware variants after user interactions. | Developer (FE) | +| 2026-03-06 | Added typed chip-context registry contract (`search-context.registry.ts`) and shifted suggestion selection to route-context arrays + bounded last-few-action prioritization + deterministic rotation. | Developer (FE) | +| 2026-03-06 | Synced architecture docs for automatic page-open suggestions and ambient `lastAction` contract: `docs/modules/ui/architecture.md`, `docs/modules/advisory-ai/knowledge-search.md`, `docs/modules/advisory-ai/unified-search-architecture.md`. | Documentation author | +| 2026-03-06 | Added UI governance rule for chip ownership and page-context interface in `docs/modules/ui/search-chip-context-contract.md`. | Documentation author | + +## Decisions & Risks +- Decision needed: whether route context should remain a hard domain filter in FE (`buildContextFilter`) or become a soft ranking hint only via ambient payload. +- Decision needed: final schema for `lastAction` ambient metadata and retention policy in FE memory/session scope. +- Decision: FE emits `ambient.lastAction` now as a forward-compatible field; current backend deployments may ignore it without regressing behavior. +- Decision: chip definitions are now governed by typed context arrays (`SEARCH_CONTEXT_DEFINITIONS`) and an explicit page-level interface contract (`SearchContextComponent`) instead of ad-hoc route conditionals. +- Docs updated: `docs/modules/ui/architecture.md`, `docs/modules/ui/search-chip-context-contract.md`, `docs/modules/advisory-ai/knowledge-search.md`, `docs/modules/advisory-ai/unified-search-architecture.md`. +- Risk: stale action context may bias suggestions toward irrelevant domains. +- Mitigation: TTL + bounded history + explicit reset on session boundaries. +- Risk: route-prefix drift between FE and backend route-domain maps can silently reduce context quality. +- Mitigation: shared route mapping tests and explicit parity checks. +- Risk: privacy leakage if raw action labels/queries are persisted beyond current controls. +- Mitigation: preserve hashed analytics and limit persisted raw content to existing approved history paths only. + +## Next Checkpoints +- 2026-03-08: Contract freeze for FE ambient payload and AdvisoryAI DTO updates. +- 2026-03-10: FE context-capture + payload emission complete with unit tests. +- 2026-03-12: AdvisoryAI processor/refinement updates complete with integration tests. +- 2026-03-13: Docs sync and rollout readiness review. diff --git a/docs/modules/advisory-ai/knowledge-search.md b/docs/modules/advisory-ai/knowledge-search.md index b58cfbbff..9a3b26458 100644 --- a/docs/modules/advisory-ai/knowledge-search.md +++ b/docs/modules/advisory-ai/knowledge-search.md @@ -119,6 +119,10 @@ Implemented in `src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSea - Supported `filters.domains`: `knowledge`, `findings`, `vex`, `policy`, `platform`. - Supported `filters.entityTypes`: `docs`, `api`, `doctor`, `finding`, `vex_statement`, `policy_rule`, `platform_entity`. - Unsupported domain/entity filter values are rejected with HTTP 400; they are not silently broadened to an unfiltered query. +- Web ambient contract: + - Global search emits ambient context with each unified query: `currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, and optional `lastAction` (`action`, `source`, `queryHint`, `domain`, `entityKey`, `route`, `occurredAt`). + - Contract remains backward-compatible: if an API deployment does not yet consume `lastAction`, unknown ambient fields are ignored and base search behavior remains unchanged. + - UI suggestion behavior now combines obvious route defaults with one strategic non-obvious suggestion and action-aware variants (for example, policy/VEX impact and incident timeline pivots). - Unified index lifecycle: - Manual rebuild endpoint: `POST /v1/search/index/rebuild`. - Optional background refresh loop is available via `KnowledgeSearchOptions` (`UnifiedAutoIndexEnabled`, `UnifiedAutoIndexOnStartup`, `UnifiedIndexRefreshIntervalSeconds`). diff --git a/docs/modules/advisory-ai/unified-search-architecture.md b/docs/modules/advisory-ai/unified-search-architecture.md index 45d81ef40..4b4471e03 100644 --- a/docs/modules/advisory-ai/unified-search-architecture.md +++ b/docs/modules/advisory-ai/unified-search-architecture.md @@ -21,6 +21,7 @@ flowchart LR ### Layer 1: Query Understanding - Input: `UnifiedSearchRequest` (`q`, filters, ambient context, session id). +- Ambient context envelope from Web clients includes route/session continuity fields and optional last-action metadata (`action`, `source`, `queryHint`, `domain`, `entityKey`, `route`, `occurredAt`) for follow-up ranking/refinement. - Components: - `EntityExtractor` - `IntentClassifier` diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 3884a38f6..15784df2c 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -2,6 +2,7 @@ ## Active Sprint Links - `docs/implplan/SPRINT_20260221_041_FE_prealpha_ia_ops_setup_rewire.md` +- `docs/implplan/SPRINT_20260306_001_Web_contextual_search_suggestions.md` ## Delivery Tasks - [DONE] 041-T1 Root IA/nav rewrite (Mission Control + Ops + Setup) @@ -16,3 +17,9 @@ - [DONE] 041-T10 Integrations consolidation for advisory + VEX - [DONE] 041-T11 Docs sync for new pre-alpha IA - [DONE] 041-T12 Targeted tests and verification evidence +- [DONE] WEB-CTX-002 FE ambient context capture (page + last action) +- [DONE] WEB-CTX-003 FE -> AdvisoryAI ambient payload activation +- [DOING] WEB-CTX-005 Context-aware suggestion UX updates +- [DOING] WEB-CTX-007 Docs sync and rollout plan +- [DONE] WEB-CTX-E2E Playwright coverage for contextual suggestions + ambient last-action payload +- [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 a5748ec7f..e9e5aab79 100644 --- a/docs/modules/ui/architecture.md +++ b/docs/modules/ui/architecture.md @@ -193,7 +193,11 @@ Each feature folder builds as a **standalone route** (lazy loaded). All HTTP sha * **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`). -* **Shared route-context suggestions**: `AmbientContextService` is the single source for route-aware suggestion sets used by both global search and AdvisoryAI chat onboarding prompts, ensuring consistent context shifts as navigation changes. +* **Automatic page-open suggestions**: `AmbientContextService` tracks router navigation and updates global-search suggestion chips/placeholders automatically for every opened page without requiring manual refresh. +* **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. +* **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`. * **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-chip-context-contract.md b/docs/modules/ui/search-chip-context-contract.md new file mode 100644 index 000000000..c8e9ae2d4 --- /dev/null +++ b/docs/modules/ui/search-chip-context-contract.md @@ -0,0 +1,45 @@ +# Search Chip Context Contract + +## Purpose +- 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. + +## Rule (mandatory for page teams) +- Every page that needs custom search chips must declare a context entry in `SEARCH_CONTEXT_DEFINITIONS`. +- Page components should implement `SearchContextComponent` with `searchContextId` so ownership is explicit. +- Context definitions must provide: + - `id` + - `routePrefixes` + - optional `domain` + - optional `searchSuggestions[]` + - optional `chatSuggestions[]` + - optional `chatRoutePattern` +- Suggestion arrays must stay deterministic and bounded: + - at most 3 base chips per page context + - short, action-oriented text + - no tenant/user secrets in fallback text + +## Source of truth +- Contract and registry: + - `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` + +## Runtime behavior +- Base chips come from the page context array. +- A deterministic rotation (session + route scope + 5-minute bucket) varies chip order. +- Last few actions for the current page scope are tracked (bounded history, TTL 15 minutes). +- Up to 2 `follow up: ...` chips are generated from recent actions and prioritized above base chips. +- One strategic chip is generated from dominant/recent action intent. + +## Page ownership workflow +1. Add/adjust a context in `SEARCH_CONTEXT_DEFINITIONS`. +2. Ensure page component exposes the same `searchContextId` (implements `SearchContextComponent`). +3. Add/adjust unit tests in `ambient-context.service.spec.ts`. +4. Add/adjust Playwright tests for route chips + action-driven chips. + +## Non-goals +- No unbounded per-page suggestion memory. +- No runtime remote fetch for chip definitions. +- No randomization based on `Math.random()` (must remain replayable). diff --git a/src/Web/StellaOps.Web/output/playwright/repro-header-search-live.cjs b/src/Web/StellaOps.Web/output/playwright/repro-header-search-live.cjs index 2774d1e31..aa1a5d201 100644 --- a/src/Web/StellaOps.Web/output/playwright/repro-header-search-live.cjs +++ b/src/Web/StellaOps.Web/output/playwright/repro-header-search-live.cjs @@ -34,7 +34,7 @@ const session = { window.__stellaopsTestSession = stubSession; }, session); - const url = process.argv[2] || 'https://127.1.0.1/'; + const url = process.argv[2] || 'https://stella-ops.local/'; console.log('[goto]', url); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForTimeout(2000); diff --git a/src/Web/StellaOps.Web/output/playwright/repro-header-search.cjs b/src/Web/StellaOps.Web/output/playwright/repro-header-search.cjs index 12c2fb266..32371dc55 100644 --- a/src/Web/StellaOps.Web/output/playwright/repro-header-search.cjs +++ b/src/Web/StellaOps.Web/output/playwright/repro-header-search.cjs @@ -34,7 +34,7 @@ const session = { window.__stellaopsTestSession = stubSession; }, session); - const url = process.argv[2] || 'https://127.1.0.1:10000/'; + const url = process.argv[2] || 'https://stella-ops.local:10000/'; console.log('[goto]', url); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForTimeout(2000); diff --git a/src/Web/StellaOps.Web/playwright.e2e.config.ts b/src/Web/StellaOps.Web/playwright.e2e.config.ts index 59ff45df9..f0ab85c5f 100644 --- a/src/Web/StellaOps.Web/playwright.e2e.config.ts +++ b/src/Web/StellaOps.Web/playwright.e2e.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ // npx playwright test --config playwright.e2e.config.ts ... // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(function (): any { - const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'https://127.1.0.1'; + const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'https://stella-ops.local'; const localSource = process.env.PLAYWRIGHT_LOCAL_SOURCE === '1'; return { ...(localSource diff --git a/src/Web/StellaOps.Web/proxy.conf.json b/src/Web/StellaOps.Web/proxy.conf.json index 38b1aa651..a6b0f6769 100644 --- a/src/Web/StellaOps.Web/proxy.conf.json +++ b/src/Web/StellaOps.Web/proxy.conf.json @@ -1,122 +1,122 @@ { "/envsettings.json": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/platform": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/api": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/authority": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/console": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/connect": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/.well-known": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/jwks": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/scanner": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/policyGateway": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/policyEngine": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/concelier": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/attestor": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/gateway": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/notify": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/scheduler": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/signals": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/excititor": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/findingsLedger": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/vexhub": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/vexlens": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/jobengine": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/graph": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/doctor": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/integrations": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/replay": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/exportcenter": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/healthz": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/policy": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false }, "/v1": { - "target": "http://127.1.0.1:80", + "target": "http://stella-ops.local:80", "secure": false } } diff --git a/src/Web/StellaOps.Web/scripts/serve.js b/src/Web/StellaOps.Web/scripts/serve.js index 21ea1ee47..436208815 100644 --- a/src/Web/StellaOps.Web/scripts/serve.js +++ b/src/Web/StellaOps.Web/scripts/serve.js @@ -116,7 +116,7 @@ async function main() { console.warn(''); console.warn(` To use https://${HOSTNAME}, add to ${hostsFilePath()}:`); console.warn(''); - console.warn(` 127.1.0.1 ${HOSTNAME}`); + console.warn(` 127.1.0.1 ${HOSTNAME} # or your preferred IP`); console.warn(''); console.warn(` See ${SETUP_DOC} for the full list of hostnames.`); console.warn(''); diff --git a/src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts b/src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts index a3ca4dc6c..12e617f72 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts @@ -9,6 +9,7 @@ import { SUPPORTED_UNIFIED_DOMAINS, SUPPORTED_UNIFIED_ENTITY_TYPES } from './uni import type { EntityCard, UnifiedEntityType, + UnifiedSearchAmbientContext, UnifiedSearchDiagnostics, UnifiedSearchDomain, UnifiedSearchFilter, @@ -36,6 +37,22 @@ interface UnifiedSearchRequestDto { }; includeSynthesis?: boolean; includeDebug?: boolean; + ambient?: { + currentRoute?: string; + visibleEntityKeys?: string[]; + recentSearches?: string[]; + sessionId?: string; + resetSession?: boolean; + lastAction?: { + action: string; + source?: string; + queryHint?: string; + domain?: string; + entityKey?: string; + route?: string; + occurredAt: string; + }; + }; } interface SearchSuggestionDto { @@ -105,6 +122,7 @@ export class UnifiedSearchClient { query: string, filter?: UnifiedSearchFilter, limit = 10, + ambient?: UnifiedSearchAmbientContext, ): Observable { const normalizedQuery = query.trim(); if (normalizedQuery.length < 1) { @@ -130,6 +148,7 @@ export class UnifiedSearchClient { filters: this.normalizeFilter(filter), includeSynthesis: true, includeDebug: false, + ambient: this.normalizeAmbient(ambient), }; return this.http @@ -421,6 +440,56 @@ export class UnifiedSearchClient { return normalized; } + private normalizeAmbient( + ambient?: UnifiedSearchAmbientContext, + ): UnifiedSearchRequestDto['ambient'] | undefined { + if (!ambient) { + return undefined; + } + + const currentRoute = ambient.currentRoute?.trim() || undefined; + const visibleEntityKeys = (ambient.visibleEntityKeys ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .slice(0, 12); + const recentSearches = (ambient.recentSearches ?? []) + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .slice(0, 10); + const sessionId = ambient.sessionId?.trim() || undefined; + const lastAction = ambient.lastAction && ambient.lastAction.action.trim().length > 0 + ? { + action: ambient.lastAction.action.trim(), + source: ambient.lastAction.source?.trim() || undefined, + queryHint: ambient.lastAction.queryHint?.trim() || undefined, + domain: ambient.lastAction.domain?.trim() || undefined, + entityKey: ambient.lastAction.entityKey?.trim() || undefined, + route: ambient.lastAction.route?.trim() || undefined, + occurredAt: ambient.lastAction.occurredAt, + } + : undefined; + + if ( + !currentRoute && + visibleEntityKeys.length === 0 && + recentSearches.length === 0 && + !sessionId && + !ambient.resetSession && + !lastAction + ) { + return undefined; + } + + return { + currentRoute, + visibleEntityKeys: visibleEntityKeys.length > 0 ? visibleEntityKeys : undefined, + recentSearches: recentSearches.length > 0 ? recentSearches : undefined, + sessionId, + resetSession: ambient.resetSession === true ? true : undefined, + lastAction, + }; + } + private normalizeSnippet(value: string): string { if (!value) { return ''; diff --git a/src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts b/src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts index 9f77e74c0..fb8e1ccc0 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts @@ -61,6 +61,25 @@ export interface SearchRefinement { source: string; } +export interface UnifiedSearchAmbientAction { + action: string; + source?: string; + queryHint?: string; + domain?: UnifiedSearchDomain; + entityKey?: string; + route?: string; + occurredAt: string; +} + +export interface UnifiedSearchAmbientContext { + currentRoute?: string; + visibleEntityKeys?: string[]; + recentSearches?: string[]; + sessionId?: string; + resetSession?: boolean; + lastAction?: UnifiedSearchAmbientAction; +} + export interface UnifiedSearchResponse { query: string; topK: number; 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 eef474901..9c5c93421 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 @@ -1,71 +1,55 @@ import { Injectable, inject, signal } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { filter } from 'rxjs/operators'; -import type { UnifiedSearchDomain, UnifiedSearchFilter } from '../api/unified-search.models'; +import type { + UnifiedSearchAmbientAction, + UnifiedSearchAmbientContext, + UnifiedSearchDomain, + UnifiedSearchFilter, +} from '../api/unified-search.models'; +import { + DEFAULT_CHAT_SUGGESTIONS, + DEFAULT_SEARCH_SUGGESTIONS, + SEARCH_CONTEXT_DEFINITIONS, + type SearchContextDefinition, + type SearchSuggestionChip, +} from './search-context.registry'; -export interface ContextSuggestion { - key: string; - fallback: string; +export type ContextSuggestion = SearchSuggestionChip; + +export interface AmbientActionInput { + action: string; + source?: string; + queryHint?: string; + domain?: UnifiedSearchDomain; + entityKey?: string; + route?: string; + occurredAt?: string; } +export interface BuildAmbientContextOptions { + visibleEntityKeys?: readonly string[]; + recentSearches?: readonly string[]; + resetSession?: boolean; +} + +type SearchSuggestionScope = + | 'findings' + | 'policy' + | 'doctor' + | 'timeline' + | 'releases' + | 'default'; + @Injectable({ providedIn: 'root' }) export class AmbientContextService { private readonly router = inject(Router); private readonly routeUrl = signal(this.router.url); - - private readonly searchSuggestionSets = { - findings: [ - { key: 'ui.search.suggestion.findings.critical', fallback: 'critical findings' }, - { key: 'ui.search.suggestion.findings.reachable', fallback: 'reachable vulnerabilities' }, - { key: 'ui.search.suggestion.findings.unresolved', fallback: 'unresolved CVEs' }, - ], - policy: [ - { key: 'ui.search.suggestion.policy.failing_gates', fallback: 'failing policy gates' }, - { key: 'ui.search.suggestion.policy.production_deny', fallback: 'production deny rules' }, - { key: 'ui.search.suggestion.policy.exceptions', fallback: 'policy exceptions' }, - ], - doctor: [ - { key: 'ui.search.suggestion.doctor.database', fallback: 'database connectivity' }, - { key: 'ui.search.suggestion.doctor.disk', fallback: 'disk space' }, - { key: 'ui.search.suggestion.doctor.oidc', fallback: 'OIDC readiness' }, - ], - timeline: [ - { key: 'ui.search.suggestion.timeline.failed_deployments', fallback: 'failed deployments' }, - { key: 'ui.search.suggestion.timeline.recent_promotions', fallback: 'recent promotions' }, - { key: 'ui.search.suggestion.timeline.release_history', fallback: 'release history' }, - ], - releases: [ - { key: 'ui.search.suggestion.releases.pending_approvals', fallback: 'pending approvals' }, - { key: 'ui.search.suggestion.releases.blocked_releases', fallback: 'blocked releases' }, - { key: 'ui.search.suggestion.releases.environment_status', fallback: 'environment status' }, - ], - default: [ - { key: 'ui.search.suggestion.default.deploy', fallback: 'How do I deploy?' }, - { key: 'ui.search.suggestion.default.vex', fallback: 'What is a VEX statement?' }, - { key: 'ui.search.suggestion.default.critical', fallback: 'Show critical findings' }, - ], - } as const satisfies Record; - - private readonly chatSuggestionSets = { - vulnerability: [ - { key: 'ui.chat.suggestion.vulnerability.exploitable', fallback: 'Is this exploitable in my environment?' }, - { key: 'ui.chat.suggestion.vulnerability.remediation', fallback: 'What is the remediation?' }, - { key: 'ui.chat.suggestion.vulnerability.evidence_chain', fallback: 'Show me the evidence chain' }, - { key: 'ui.chat.suggestion.vulnerability.draft_vex', fallback: 'Draft a VEX statement' }, - ], - policy: [ - { key: 'ui.chat.suggestion.policy.explain_rule', fallback: 'Explain this policy rule' }, - { key: 'ui.chat.suggestion.policy.override_gate', fallback: 'What would happen if I override this gate?' }, - { key: 'ui.chat.suggestion.policy.recent_violations', fallback: 'Show me recent policy violations' }, - { key: 'ui.chat.suggestion.policy.add_exception', fallback: 'How do I add an exception?' }, - ], - default: [ - { key: 'ui.chat.suggestion.default.what_can_do', fallback: 'What can Stella Ops do?' }, - { key: 'ui.chat.suggestion.default.first_scan', fallback: 'How do I set up my first scan?' }, - { key: 'ui.chat.suggestion.default.promotion_workflow', fallback: 'Explain the release promotion workflow' }, - { key: 'ui.chat.suggestion.default.health_checks', fallback: 'What health checks should I run first?' }, - ], - } as const satisfies Record; + private readonly actionHistoryByScope = signal>({}); + private readonly actionTtlMs = 15 * 60 * 1000; + private readonly maxActionsPerScope = 6; + private readonly sessionStorageKey = 'stella-search-session-id'; + private readonly sessionId = this.resolveSessionId(); constructor() { this.router.events @@ -74,77 +58,48 @@ export class AmbientContextService { } currentDomain(): UnifiedSearchDomain | null { - const url = this.routeUrl(); - - if (url.startsWith('/security/triage') || url.startsWith('/security/findings')) { - return 'findings'; - } - - if (url.startsWith('/security/advisories-vex') || url.startsWith('/vex-hub')) { - return 'vex'; - } - - if (url.startsWith('/ops/policy')) { - return 'policy'; - } - - if (url.startsWith('/ops/operations/doctor') || url.startsWith('/ops/operations/system-health')) { - return 'knowledge'; - } - - if (url.startsWith('/ops/graph') || url.startsWith('/security/reach')) { - return 'graph'; - } - - if (url.startsWith('/ops/operations/jobs') || url.startsWith('/ops/operations/scheduler')) { - return 'ops_memory'; - } - - if (url.startsWith('/ops/timeline') || url.startsWith('/audit')) { - return 'timeline'; - } - - return null; + const context = this.findContext(this.routeUrl(), (candidate) => candidate.domain !== undefined); + return context?.domain ?? null; } getSearchSuggestions(): readonly ContextSuggestion[] { - const url = this.routeUrl(); + const route = this.routeUrl(); + const scope = this.resolveSearchSuggestionScope(route); + const scopeKey = this.routeScope(route); - if (url.startsWith('/security/triage') || url.startsWith('/security/findings')) { - return this.searchSuggestionSets.findings; - } + const context = this.findContext(route, (candidate) => + Array.isArray(candidate.searchSuggestions) && candidate.searchSuggestions.length > 0, + ); + const routeSuggestions = context?.searchSuggestions ?? DEFAULT_SEARCH_SUGGESTIONS; + const recentActions = this.getActiveActions(scopeKey); + const actionSuggestions = this.buildRecentActionSuggestions(recentActions, 2); + const strategicSuggestion = this.buildStrategicSuggestion(scope, recentActions); + const rotatedRouteSuggestions = this.rotateSuggestions(routeSuggestions, `${scope}|${scopeKey}`); - if (url.startsWith('/ops/policy')) { - return this.searchSuggestionSets.policy; - } + const deduped = [...actionSuggestions, strategicSuggestion, ...rotatedRouteSuggestions] + .filter((entry): entry is ContextSuggestion => entry !== null) + .filter((entry, index, list) => + list.findIndex((candidate) => candidate.fallback.toLowerCase() === entry.fallback.toLowerCase()) === index, + ); - if (url.startsWith('/ops/operations/doctor') || url.startsWith('/ops/operations/system-health')) { - return this.searchSuggestionSets.doctor; - } - - if (url.startsWith('/ops/timeline') || url.startsWith('/audit')) { - return this.searchSuggestionSets.timeline; - } - - if (url.startsWith('/releases') || url.startsWith('/mission-control')) { - return this.searchSuggestionSets.releases; - } - - return this.searchSuggestionSets.default; + return deduped.slice(0, 4); } getChatSuggestions(): readonly ContextSuggestion[] { - const url = this.routeUrl(); + const route = this.routeUrl(); + const context = this.findContext(route, (candidate) => { + if (!candidate.chatSuggestions || candidate.chatSuggestions.length === 0) { + return false; + } - if (url.match(/\/security\/(findings|triage)\/[^/]+/)) { - return this.chatSuggestionSets.vulnerability; - } + if (candidate.chatRoutePattern) { + return candidate.chatRoutePattern.test(route); + } - if (url.startsWith('/ops/policy')) { - return this.chatSuggestionSets.policy; - } + return true; + }); - return this.chatSuggestionSets.default; + return context?.chatSuggestions ?? DEFAULT_CHAT_SUGGESTIONS; } buildContextFilter(): UnifiedSearchFilter { @@ -155,4 +110,465 @@ export class AmbientContextService { return { domains: [domain] }; } + + buildAmbientContext(options: BuildAmbientContextOptions = {}): UnifiedSearchAmbientContext { + const currentRoute = this.normalizeRoute(this.routeUrl()); + const scope = this.routeScope(currentRoute); + const recentActions = this.getActiveActions(scope); + const lastAction = recentActions[0] ?? null; + const visibleEntityKeys = this.normalizeList(options.visibleEntityKeys, 12); + const recentSearches = this.normalizeList(options.recentSearches, 10); + + const ambient: UnifiedSearchAmbientContext = { + currentRoute: currentRoute || undefined, + visibleEntityKeys: visibleEntityKeys.length > 0 ? visibleEntityKeys : undefined, + recentSearches: recentSearches.length > 0 ? recentSearches : undefined, + sessionId: this.sessionId, + resetSession: options.resetSession === true ? true : undefined, + lastAction: lastAction ?? undefined, + }; + + return ambient; + } + + recordAction(action: AmbientActionInput): void { + if (typeof action.action !== 'string' || action.action.trim().length === 0) { + return; + } + + const route = this.normalizeRoute(action.route ?? this.routeUrl()); + const scope = this.routeScope(route); + const occurredAt = this.normalizeOccurredAt(action.occurredAt); + const normalized: UnifiedSearchAmbientAction = { + action: action.action.trim().slice(0, 64), + source: this.normalizeOptionalText(action.source, 48), + queryHint: this.normalizeOptionalText(action.queryHint, 96), + domain: action.domain, + entityKey: this.normalizeOptionalText(action.entityKey, 120), + route, + occurredAt, + }; + + this.actionHistoryByScope.update((state) => { + const existing = this.pruneActions(state[scope] ?? []); + const deduped = [normalized, ...existing] + .filter((entry, index, list) => + list.findIndex((candidate) => this.actionIdentity(candidate) === this.actionIdentity(entry)) === index, + ) + .slice(0, this.maxActionsPerScope); + + return { + ...state, + [scope]: deduped, + }; + }); + } + + private buildRecentActionSuggestions( + actions: readonly UnifiedSearchAmbientAction[], + maxCount: number, + ): readonly ContextSuggestion[] { + const suggestions: ContextSuggestion[] = []; + for (const action of actions) { + const hint = this.buildActionHint(action); + if (!hint) { + continue; + } + + suggestions.push({ + key: 'ui.search.suggestion.last_action.follow_up', + fallback: `follow up: ${hint}`, + }); + + if (suggestions.length >= maxCount) { + break; + } + } + + return suggestions; + } + + private buildStrategicSuggestion( + scope: SearchSuggestionScope, + actions: readonly UnifiedSearchAmbientAction[], + ): ContextSuggestion | null { + const latestAction = actions[0] ?? null; + const dominantAction = this.resolveDominantAction(actions); + const actionDriven = this.buildActionDrivenStrategicSuggestion(dominantAction ?? latestAction); + if (actionDriven) { + return actionDriven; + } + + const hint = latestAction ? this.buildActionHint(latestAction) : null; + switch (scope) { + case 'findings': + return { + key: 'ui.search.suggestion.contextual.findings.policy_vex', + fallback: hint + ? `policy and VEX impact of ${hint}` + : 'policy and VEX impact of critical findings', + }; + case 'policy': + return { + key: 'ui.search.suggestion.contextual.policy.findings_impact', + fallback: hint + ? `findings impacted by policy ${hint}` + : 'findings impacted by current policy rules', + }; + case 'doctor': + return { + key: 'ui.search.suggestion.contextual.doctor.release_blockers', + fallback: hint + ? `release blockers caused by ${hint}` + : 'release blockers caused by failing health checks', + }; + case 'timeline': + return { + key: 'ui.search.suggestion.contextual.timeline.policy_change', + fallback: hint + ? `policy or config changes before ${hint}` + : 'policy or config changes before recent incidents', + }; + case 'releases': + return { + key: 'ui.search.suggestion.contextual.releases.blockers', + fallback: hint + ? `what blocked release of ${hint}` + : 'what blocked recent promotions', + }; + default: + return { + key: 'ui.search.suggestion.contextual.default.delta', + fallback: 'what changed since the last successful promotion', + }; + } + } + + private buildActionDrivenStrategicSuggestion( + action: UnifiedSearchAmbientAction | null, + ): ContextSuggestion | null { + if (!action) { + return null; + } + + const hint = this.buildActionHint(action); + const normalizedAction = action.action.trim().toLowerCase(); + + if (normalizedAction === 'chat_search_for_more' || normalizedAction === 'chat_search_related') { + return { + key: 'ui.search.suggestion.contextual.chat.policy_vex', + fallback: hint + ? `policy and VEX impact of ${hint}` + : 'policy and VEX impact of this issue', + }; + } + + if (normalizedAction === 'search_result_open' || normalizedAction === 'search_result_action') { + return { + key: 'ui.search.suggestion.contextual.search_result.timeline', + fallback: hint + ? `incident timeline and related exposures for ${hint}` + : 'incident timeline and related exposures', + }; + } + + if (normalizedAction === 'search_to_chat' || normalizedAction === 'search_to_chat_synthesis') { + return { + key: 'ui.search.suggestion.contextual.search_to_chat.evidence', + fallback: hint + ? `evidence chain and policy rationale for ${hint}` + : 'evidence chain and policy rationale', + }; + } + + return null; + } + + private resolveDominantAction( + actions: readonly UnifiedSearchAmbientAction[], + ): UnifiedSearchAmbientAction | null { + if (actions.length === 0) { + return null; + } + + const counts = new Map(); + for (const action of actions) { + const key = action.action.trim().toLowerCase(); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + let selected: UnifiedSearchAmbientAction | null = null; + let highestCount = 0; + for (const action of actions) { + const key = action.action.trim().toLowerCase(); + const count = counts.get(key) ?? 0; + if (count > highestCount) { + highestCount = count; + selected = action; + } + } + + return selected; + } + + private buildActionHint(action: UnifiedSearchAmbientAction): string | null { + const rawHint = action.queryHint ?? action.entityKey ?? action.domain ?? action.action; + const normalized = this.normalizeOptionalText(rawHint, 72); + if (!normalized) { + return null; + } + + if (normalized.length <= 2) { + return null; + } + + return normalized; + } + + private getActiveActions(scope: string): UnifiedSearchAmbientAction[] { + const state = this.actionHistoryByScope(); + const actions = state[scope] ?? []; + const pruned = this.pruneActions(actions); + + if (pruned.length !== actions.length) { + this.setScopeActions(scope, pruned); + } + + return pruned; + } + + private pruneActions( + actions: readonly UnifiedSearchAmbientAction[], + ): UnifiedSearchAmbientAction[] { + const now = Date.now(); + return actions.filter((action) => { + const occurredAtMs = Date.parse(action.occurredAt); + if (!Number.isFinite(occurredAtMs)) { + return false; + } + + return now - occurredAtMs <= this.actionTtlMs; + }); + } + + private setScopeActions(scope: string, actions: readonly UnifiedSearchAmbientAction[]): void { + this.actionHistoryByScope.update((state) => { + const next: Record = { ...state }; + if (actions.length === 0) { + delete next[scope]; + } else { + next[scope] = [...actions]; + } + return next; + }); + } + + private actionIdentity(action: UnifiedSearchAmbientAction): string { + return [ + action.action, + action.source ?? '', + action.queryHint ?? '', + action.domain ?? '', + action.entityKey ?? '', + action.route ?? '', + ] + .join('|') + .toLowerCase(); + } + + private resolveSearchSuggestionScope(route: string): SearchSuggestionScope { + const context = this.findContext(route, (candidate) => + Array.isArray(candidate.searchSuggestions) && candidate.searchSuggestions.length > 0, + ); + + switch (context?.id) { + case 'findings': + return 'findings'; + case 'policy': + return 'policy'; + case 'doctor': + return 'doctor'; + case 'timeline': + return 'timeline'; + case 'releases': + return 'releases'; + default: + return 'default'; + } + } + + private rotateSuggestions( + suggestions: readonly ContextSuggestion[], + scope: string, + ): readonly ContextSuggestion[] { + if (suggestions.length <= 1) { + return suggestions; + } + + // Rotate by a deterministic hash (session + scope + 5-minute bucket) to avoid + // static ordering while keeping stable UX within a short time window. + const bucket = Math.floor(Date.now() / (5 * 60 * 1000)); + const seed = `${this.sessionId}|${scope}|${bucket}`; + const offset = this.stableHash(seed) % suggestions.length; + + if (offset === 0) { + return suggestions; + } + + return [ + ...suggestions.slice(offset), + ...suggestions.slice(0, offset), + ]; + } + + private stableHash(value: string): number { + let hash = 0; + for (let index = 0; index < value.length; index++) { + hash = ((hash << 5) - hash) + value.charCodeAt(index); + hash |= 0; + } + + return Math.abs(hash); + } + + private findContext( + route: string, + predicate?: (context: SearchContextDefinition) => boolean, + ): SearchContextDefinition | null { + const normalizedRoute = this.normalizeRoute(route).toLowerCase(); + const path = normalizedRoute.split('?')[0]; + + for (const context of SEARCH_CONTEXT_DEFINITIONS) { + const matchesRoute = context.routePrefixes.some((prefix) => + path.startsWith(prefix.toLowerCase()), + ); + if (!matchesRoute) { + continue; + } + + if (predicate && !predicate(context)) { + continue; + } + + return context; + } + + return null; + } + + private routeScope(route: string): string { + const normalizedRoute = this.normalizeRoute(route).toLowerCase(); + const path = normalizedRoute.split('?')[0]; + const segments = path.split('/').filter((segment) => segment.length > 0); + + if (segments.length === 0) { + return '/'; + } + + if (segments[0] === 'ops' && segments[1] === 'operations' && segments[2]) { + return `/ops/operations/${segments[2]}`; + } + + if (segments[0] === 'security' && segments[1]) { + return `/security/${segments[1]}`; + } + + if (segments.length >= 2) { + return `/${segments[0]}/${segments[1]}`; + } + + return `/${segments[0]}`; + } + + private normalizeRoute(route: string): string { + if (!route) { + return '/'; + } + + return route.trim(); + } + + private normalizeList(values: readonly string[] | undefined, maxSize: number): string[] { + if (!values || values.length === 0) { + return []; + } + + const normalized: string[] = []; + const seen = new Set(); + for (const value of values) { + const item = this.normalizeOptionalText(value, 180); + if (!item) { + continue; + } + + const key = item.toLowerCase(); + if (seen.has(key)) { + continue; + } + + normalized.push(item); + seen.add(key); + if (normalized.length >= maxSize) { + break; + } + } + + return normalized; + } + + private normalizeOptionalText(value: string | undefined, maxLength: number): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed.length > maxLength + ? trimmed.slice(0, maxLength).trimEnd() + : trimmed; + } + + private normalizeOccurredAt(occurredAt: string | undefined): string { + if (!occurredAt) { + return new Date().toISOString(); + } + + const parsed = Date.parse(occurredAt); + if (!Number.isFinite(parsed)) { + return new Date().toISOString(); + } + + return new Date(parsed).toISOString(); + } + + private resolveSessionId(): string { + const fallback = 'web-search-session'; + if (typeof window === 'undefined') { + return fallback; + } + + try { + const existing = window.sessionStorage.getItem(this.sessionStorageKey); + if (existing && existing.trim().length > 0) { + return existing; + } + + const generated = this.generateSessionId(); + window.sessionStorage.setItem(this.sessionStorageKey, generated); + return generated; + } catch { + return fallback; + } + } + + private generateSessionId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return `session-${Date.now().toString(36)}`; + } } diff --git a/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts b/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts index b2f75a74f..3f1ce1427 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/search-chat-context.service.ts @@ -13,6 +13,7 @@ export interface ChatToSearchContext { query: string; domain?: UnifiedSearchDomain; entityKey?: string; + action?: 'chat_search_for_more' | 'chat_search_related'; } @Injectable({ providedIn: 'root' }) 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 new file mode 100644 index 000000000..a5046183c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts @@ -0,0 +1,135 @@ +import type { UnifiedSearchDomain } from '../api/unified-search.models'; + +export interface SearchSuggestionChip { + key: string; + fallback: string; +} + +export interface SearchContextDefinition { + id: string; + routePrefixes: readonly string[]; + domain?: UnifiedSearchDomain; + searchSuggestions?: readonly SearchSuggestionChip[]; + chatSuggestions?: readonly SearchSuggestionChip[]; + chatRoutePattern?: RegExp; +} + +/** + * Page-level contract for teams that want to expose explicit chip context. + * Components can implement this interface and map `searchContextId` to a + * registry entry in `SEARCH_CONTEXT_DEFINITIONS`. + */ +export interface SearchContextComponent { + readonly searchContextId: string; +} + +export const DEFAULT_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.search.suggestion.default.deploy', fallback: 'How do I deploy?' }, + { key: 'ui.search.suggestion.default.vex', fallback: 'What is a VEX statement?' }, + { key: 'ui.search.suggestion.default.critical', fallback: 'Show critical findings' }, +]; + +export const DEFAULT_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.chat.suggestion.default.what_can_do', fallback: 'What can Stella Ops do?' }, + { key: 'ui.chat.suggestion.default.first_scan', fallback: 'How do I set up my first scan?' }, + { key: 'ui.chat.suggestion.default.promotion_workflow', fallback: 'Explain the release promotion workflow' }, + { key: 'ui.chat.suggestion.default.health_checks', fallback: 'What health checks should I run first?' }, +]; + +const FINDINGS_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.search.suggestion.findings.critical', fallback: 'critical findings' }, + { key: 'ui.search.suggestion.findings.reachable', fallback: 'reachable vulnerabilities' }, + { key: 'ui.search.suggestion.findings.unresolved', fallback: 'unresolved CVEs' }, +]; + +const POLICY_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.search.suggestion.policy.failing_gates', fallback: 'failing policy gates' }, + { key: 'ui.search.suggestion.policy.production_deny', fallback: 'production deny rules' }, + { key: 'ui.search.suggestion.policy.exceptions', fallback: 'policy exceptions' }, +]; + +const DOCTOR_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.search.suggestion.doctor.database', fallback: 'database connectivity' }, + { key: 'ui.search.suggestion.doctor.disk', fallback: 'disk space' }, + { key: 'ui.search.suggestion.doctor.oidc', fallback: 'OIDC readiness' }, +]; + +const TIMELINE_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.search.suggestion.timeline.failed_deployments', fallback: 'failed deployments' }, + { key: 'ui.search.suggestion.timeline.recent_promotions', fallback: 'recent promotions' }, + { key: 'ui.search.suggestion.timeline.release_history', fallback: 'release history' }, +]; + +const RELEASES_SEARCH_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.search.suggestion.releases.pending_approvals', fallback: 'pending approvals' }, + { key: 'ui.search.suggestion.releases.blocked_releases', fallback: 'blocked releases' }, + { key: 'ui.search.suggestion.releases.environment_status', fallback: 'environment status' }, +]; + +const FINDINGS_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.chat.suggestion.vulnerability.exploitable', fallback: 'Is this exploitable in my environment?' }, + { key: 'ui.chat.suggestion.vulnerability.remediation', fallback: 'What is the remediation?' }, + { key: 'ui.chat.suggestion.vulnerability.evidence_chain', fallback: 'Show me the evidence chain' }, + { key: 'ui.chat.suggestion.vulnerability.draft_vex', fallback: 'Draft a VEX statement' }, +]; + +const POLICY_CHAT_SUGGESTIONS: readonly SearchSuggestionChip[] = [ + { key: 'ui.chat.suggestion.policy.explain_rule', fallback: 'Explain this policy rule' }, + { key: 'ui.chat.suggestion.policy.override_gate', fallback: 'What would happen if I override this gate?' }, + { key: 'ui.chat.suggestion.policy.recent_violations', fallback: 'Show me recent policy violations' }, + { key: 'ui.chat.suggestion.policy.add_exception', fallback: 'How do I add an exception?' }, +]; + +export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ + { + id: 'findings', + routePrefixes: ['/security/triage', '/security/findings'], + domain: 'findings', + searchSuggestions: FINDINGS_SEARCH_SUGGESTIONS, + }, + { + id: 'findings-chat-detail', + routePrefixes: ['/security/triage', '/security/findings'], + chatRoutePattern: /^\/security\/(findings|triage)\/[^/]+/i, + chatSuggestions: FINDINGS_CHAT_SUGGESTIONS, + }, + { + id: 'vex', + routePrefixes: ['/security/advisories-vex', '/vex-hub'], + domain: 'vex', + }, + { + id: 'policy', + routePrefixes: ['/ops/policy'], + domain: 'policy', + searchSuggestions: POLICY_SEARCH_SUGGESTIONS, + chatSuggestions: POLICY_CHAT_SUGGESTIONS, + }, + { + id: 'doctor', + routePrefixes: ['/ops/operations/doctor', '/ops/operations/system-health'], + domain: 'knowledge', + searchSuggestions: DOCTOR_SEARCH_SUGGESTIONS, + }, + { + id: 'graph', + routePrefixes: ['/ops/graph', '/security/reach'], + domain: 'graph', + }, + { + id: 'ops-memory', + routePrefixes: ['/ops/operations/jobs', '/ops/operations/scheduler'], + domain: 'ops_memory', + }, + { + id: 'timeline', + routePrefixes: ['/ops/timeline', '/audit'], + domain: 'timeline', + searchSuggestions: TIMELINE_SEARCH_SUGGESTIONS, + }, + { + id: 'releases', + routePrefixes: ['/releases', '/mission-control'], + searchSuggestions: RELEASES_SEARCH_SUGGESTIONS, + }, +] as const; diff --git a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts index 41708835d..959e40729 100644 --- a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat-message.component.ts @@ -562,6 +562,8 @@ export class ChatMessageComponent { this.searchChatContext.setChatToSearch({ query, domain, + entityKey: firstCitation?.path, + action: 'chat_search_for_more', }); this.searchForMore.emit(query); } @@ -569,7 +571,12 @@ export class ChatMessageComponent { onSearchRelated(citation: { type: string; path: string }): void { const query = this.extractSearchQueryFromCitation(citation.type, citation.path); const domain = this.mapCitationTypeToDomain(citation.type); - this.searchChatContext.setChatToSearch({ query, domain }); + this.searchChatContext.setChatToSearch({ + query, + domain, + entityKey: citation.path, + action: 'chat_search_related', + }); this.searchForMore.emit(query); } 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 85df786a8..bde82e622 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 @@ -876,7 +876,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { this.isLoading.set(true); const contextFilter = this.ambientContext.buildContextFilter(); - return this.searchClient.search(term, contextFilter).pipe( + const ambient = this.buildAmbientSnapshot(); + return this.searchClient.search(term, contextFilter, 10, ambient).pipe( catchError(() => of({ query: term, @@ -1036,6 +1037,13 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { this.emitClickAnalytics(card); const primaryAction = card.actions.find((a) => a.isPrimary) ?? card.actions[0]; if (primaryAction) { + this.recordAmbientAction('search_result_open', { + source: 'global_search_card_select', + queryHint: this.query().trim() || card.title, + domain: card.domain, + entityKey: card.entityKey, + route: primaryAction.route, + }); this.executeAction(primaryAction); } this.closeResults(); @@ -1044,12 +1052,25 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { onCardAction(card: EntityCard, action: EntityCardAction): void { this.saveRecentSearch(this.query()); this.emitClickAnalytics(card); + this.recordAmbientAction('search_result_action', { + source: 'global_search_card_action', + queryHint: this.query().trim() || card.title, + domain: card.domain, + entityKey: card.entityKey, + route: action.route, + }); this.executeAction(action); this.closeResults(); } onAskAiFromCard(card: EntityCard): void { const askPrompt = this.buildAskAiPromptForCard(card); + this.recordAmbientAction('search_to_chat', { + source: 'global_search_ask_ai_card', + queryHint: this.query().trim() || card.title, + domain: card.domain, + entityKey: card.entityKey, + }); this.searchChatContext.setSearchToChat({ query: this.query().trim() || card.title, entityCards: [card], @@ -1064,6 +1085,10 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { onAskAiFromSynthesis(): void { const askPrompt = this.buildAskAiPromptForSynthesis(); + this.recordAmbientAction('search_to_chat_synthesis', { + source: 'global_search_ask_ai_synthesis', + queryHint: this.query().trim(), + }); this.searchChatContext.setSearchToChat({ query: this.query(), entityCards: this.filteredCards(), @@ -1112,28 +1137,49 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { } selectRecent(query: string): void { + this.recordAmbientAction('search_recent', { + source: 'global_search_recent', + queryHint: query, + }); this.query.set(query); this.searchTerms$.next(query.trim()); } applyExampleQuery(example: string): void { + this.recordAmbientAction('search_example', { + source: 'global_search_example_chip', + queryHint: example, + }); this.query.set(example); this.searchTerms$.next(example.trim()); } applySuggestion(text: string): void { + this.recordAmbientAction('search_suggestion', { + source: 'global_search_did_you_mean', + queryHint: text, + }); this.query.set(text); this.saveRecentSearch(text); this.searchTerms$.next(text.trim()); } applyRefinement(refinement: SearchRefinement): void { + this.recordAmbientAction('search_refinement', { + source: 'global_search_refinement', + queryHint: refinement.text, + }); this.query.set(refinement.text); this.saveRecentSearch(refinement.text); this.searchTerms$.next(refinement.text.trim()); } navigateQuickAction(route: string): void { + this.recordAmbientAction('search_quick_action', { + source: 'global_search_quick_action', + queryHint: route, + route, + }); this.closeResults(); void this.router.navigateByUrl(route); } @@ -1209,6 +1255,13 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { if (primaryAction) { this.saveRecentSearch(this.query()); this.emitClickAnalytics(selected); + this.recordAmbientAction('search_result_open', { + source: 'global_search_keyboard_primary', + queryHint: this.query().trim() || selected.title, + domain: selected.domain, + entityKey: selected.entityKey, + route: primaryAction.route, + }); this.executeAction(primaryAction); this.closeResults(); } @@ -1360,12 +1413,45 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { this.expandedCardKey.set(null); this.isFocused.set(true); this.pendingDomainFilter.set(context.domain ?? null); + this.recordAmbientAction(context.action ?? 'chat_to_search', { + source: 'advisory_ai_chat', + queryHint: query, + domain: context.domain, + entityKey: context.entityKey, + }); this.searchTerms$.next(query); this.saveRecentSearch(query); setTimeout(() => this.searchInputRef?.nativeElement?.focus(), 0); } + private buildAmbientSnapshot() { + return this.ambientContext.buildAmbientContext({ + visibleEntityKeys: this.filteredCards().map((card) => card.entityKey), + recentSearches: this.recentSearches(), + }); + } + + private recordAmbientAction( + action: string, + options: { + source?: string; + queryHint?: string; + domain?: UnifiedSearchDomain; + entityKey?: string; + route?: string; + } = {}, + ): void { + this.ambientContext.recordAction({ + action, + source: options.source, + queryHint: options.queryHint, + domain: options.domain, + entityKey: options.entityKey, + route: options.route, + }); + } + private buildAskAiPromptForCard(card: EntityCard): string { switch (card.domain) { case 'findings': diff --git a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts index 09f77c7ac..21505a3e6 100644 --- a/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/advisory_ai_chat/chat-message.component.spec.ts @@ -3,6 +3,7 @@ import { provideRouter } from '@angular/router'; import { ChatMessageComponent } from '../../app/features/advisory-ai/chat/chat-message.component'; import { ConversationTurn } from '../../app/features/advisory-ai/chat/chat.models'; +import { SearchChatContextService } from '../../app/core/services/search-chat-context.service'; const assistantTurn: ConversationTurn = { turnId: 'turn-2', @@ -23,6 +24,7 @@ const assistantTurn: ConversationTurn = { describe('ChatMessageComponent (advisory_ai_chat)', () => { let fixture: ComponentFixture; let component: ChatMessageComponent; + let searchChatContext: SearchChatContextService; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -32,6 +34,7 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { fixture = TestBed.createComponent(ChatMessageComponent); component = fixture.componentInstance; + searchChatContext = TestBed.inject(SearchChatContextService); component.turn = assistantTurn; fixture.detectChanges(); }); @@ -61,4 +64,29 @@ describe('ChatMessageComponent (advisory_ai_chat)', () => { component.onLinkNavigate(firstLink!.link!); expect(emitSpy).toHaveBeenCalledWith(firstLink!.link!); }); + + it('sets chat-to-search context with action metadata for search-for-more', () => { + const contextSpy = spyOn(searchChatContext, 'setChatToSearch'); + + component.onSearchForMore(); + + expect(contextSpy).toHaveBeenCalledWith(jasmine.objectContaining({ + action: 'chat_search_for_more', + domain: 'findings', + entityKey: 'api-gateway:grpc.Server', + })); + }); + + it('sets chat-to-search context with action metadata for search-related', () => { + const contextSpy = spyOn(searchChatContext, 'setChatToSearch'); + + component.onSearchRelated({ type: 'policy', path: 'DENY-CRITICAL-PROD:1' }); + + expect(contextSpy).toHaveBeenCalledWith({ + query: 'DENY-CRITICAL-PROD', + domain: 'policy', + entityKey: 'DENY-CRITICAL-PROD:1', + action: 'chat_search_related', + }); + }); }); 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 d51e1bd9b..46fc52733 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 @@ -23,15 +23,15 @@ describe('AmbientContextService', () => { }); }); - it('returns findings domain and findings suggestions for triage routes', () => { + it('returns findings domain with baseline and strategic suggestions for triage routes', () => { const service = TestBed.inject(AmbientContextService); + const keys = service.getSearchSuggestions().map((item) => item.key); expect(service.currentDomain()).toBe('findings'); - expect(service.getSearchSuggestions().map((item) => item.key)).toEqual([ - 'ui.search.suggestion.findings.critical', - 'ui.search.suggestion.findings.reachable', - 'ui.search.suggestion.findings.unresolved', - ]); + expect(keys).toContain('ui.search.suggestion.contextual.findings.policy_vex'); + expect(keys).toContain('ui.search.suggestion.findings.critical'); + expect(keys).toContain('ui.search.suggestion.findings.reachable'); + expect(keys).toContain('ui.search.suggestion.findings.unresolved'); }); it('updates search and chat suggestion sets when route changes', () => { @@ -41,11 +41,11 @@ describe('AmbientContextService', () => { events.next(new NavigationEnd(1, '/ops/policy', '/ops/policy')); expect(service.currentDomain()).toBe('policy'); - expect(service.getSearchSuggestions().map((item) => item.key)).toEqual([ - 'ui.search.suggestion.policy.failing_gates', - 'ui.search.suggestion.policy.production_deny', - 'ui.search.suggestion.policy.exceptions', - ]); + const searchSuggestionKeys = service.getSearchSuggestions().map((item) => item.key); + expect(searchSuggestionKeys).toContain('ui.search.suggestion.contextual.policy.findings_impact'); + expect(searchSuggestionKeys).toContain('ui.search.suggestion.policy.failing_gates'); + expect(searchSuggestionKeys).toContain('ui.search.suggestion.policy.production_deny'); + expect(searchSuggestionKeys).toContain('ui.search.suggestion.policy.exceptions'); expect(service.getChatSuggestions().map((item) => item.key)).toEqual([ 'ui.chat.suggestion.policy.explain_rule', 'ui.chat.suggestion.policy.override_gate', @@ -53,5 +53,85 @@ describe('AmbientContextService', () => { 'ui.chat.suggestion.policy.add_exception', ]); }); -}); + it('prepends a follow-up suggestion from last action in the current route scope', () => { + const service = TestBed.inject(AmbientContextService); + service.recordAction({ + action: 'chat_search_related', + source: 'advisory_ai_chat', + queryHint: 'CVE-2024-21626', + domain: 'findings', + entityKey: 'finding:fnd-1', + }); + + const suggestions = service.getSearchSuggestions(); + expect(suggestions[0]).toEqual({ + key: 'ui.search.suggestion.last_action.follow_up', + fallback: 'follow up: CVE-2024-21626', + }); + expect(suggestions.map((item) => item.key)).toContain('ui.search.suggestion.contextual.chat.policy_vex'); + }); + + it('uses the last few actions to generate multiple follow-up suggestions', () => { + const service = TestBed.inject(AmbientContextService); + service.recordAction({ + action: 'search_result_open', + queryHint: 'CVE-2024-21626', + domain: 'findings', + }); + service.recordAction({ + action: 'search_result_action', + queryHint: 'api-gateway', + domain: 'findings', + }); + + const followUps = service + .getSearchSuggestions() + .filter((item) => item.key === 'ui.search.suggestion.last_action.follow_up') + .map((item) => item.fallback); + + expect(followUps).toContain('follow up: api-gateway'); + expect(followUps).toContain('follow up: CVE-2024-21626'); + }); + + it('expires stale last-action suggestions after TTL', () => { + const service = TestBed.inject(AmbientContextService); + service.recordAction({ + action: 'search_result_open', + source: 'global_search', + queryHint: 'critical findings', + occurredAt: new Date(Date.now() - (16 * 60 * 1000)).toISOString(), + }); + + const hasFollowUpSuggestion = service + .getSearchSuggestions() + .some((item) => item.key === 'ui.search.suggestion.last_action.follow_up'); + + expect(hasFollowUpSuggestion).toBeFalse(); + }); + + it('builds deterministic ambient context snapshots with current route and bounded lists', () => { + const service = TestBed.inject(AmbientContextService); + service.recordAction({ + action: 'search_result_action', + queryHint: 'api-gateway', + domain: 'findings', + entityKey: 'finding:fnd-2', + }); + + const ambient = service.buildAmbientContext({ + visibleEntityKeys: [' finding:fnd-2 ', 'finding:fnd-2', 'finding:fnd-3', ''], + recentSearches: ['critical findings', '', 'critical findings'], + resetSession: true, + }); + + expect(ambient.currentRoute).toBe('/security/triage'); + expect(ambient.visibleEntityKeys).toEqual(['finding:fnd-2', 'finding:fnd-3']); + expect(ambient.recentSearches).toEqual(['critical findings']); + expect(ambient.sessionId).toBeTruthy(); + expect(ambient.resetSession).toBeTrue(); + expect(ambient.lastAction?.action).toBe('search_result_action'); + expect(ambient.lastAction?.domain).toBe('findings'); + expect(ambient.lastAction?.entityKey).toBe('finding:fnd-2'); + }); +}); 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 d970167ae..622f399dc 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 @@ -13,6 +13,7 @@ describe('GlobalSearchComponent', () => { let fixture: ComponentFixture; let component: GlobalSearchComponent; let searchClient: jasmine.SpyObj; + let ambientContext: jasmine.SpyObj; let routerEvents: Subject; let router: { url: string; events: Subject; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy }; let searchChatContext: jasmine.SpyObj; @@ -49,6 +50,24 @@ describe('GlobalSearchComponent', () => { })); searchClient.getHistory.and.returnValue(of([])); + ambientContext = jasmine.createSpyObj('AmbientContextService', [ + 'buildContextFilter', + 'getSearchSuggestions', + 'buildAmbientContext', + 'recordAction', + ]) as jasmine.SpyObj; + ambientContext.buildContextFilter.and.returnValue(undefined); + ambientContext.getSearchSuggestions.and.returnValue([ + { key: 'ui.search.suggestion.default.deploy', fallback: 'How do I deploy?' }, + { key: 'ui.search.suggestion.default.vex', fallback: 'What is a VEX statement?' }, + { key: 'ui.search.suggestion.default.critical', fallback: 'Show critical findings' }, + ]); + ambientContext.buildAmbientContext.and.returnValue({ + currentRoute: '/security/triage', + recentSearches: [], + sessionId: 'session-test', + }); + searchChatContext = jasmine.createSpyObj('SearchChatContextService', [ 'consumeChatToSearch', 'setSearchToChat', @@ -61,17 +80,7 @@ describe('GlobalSearchComponent', () => { providers: [ { provide: Router, useValue: router }, { provide: UnifiedSearchClient, useValue: searchClient }, - { - provide: AmbientContextService, - useValue: { - buildContextFilter: () => undefined, - getSearchSuggestions: () => [ - { key: 'ui.search.suggestion.default.deploy', fallback: 'How do I deploy?' }, - { key: 'ui.search.suggestion.default.vex', fallback: 'What is a VEX statement?' }, - { key: 'ui.search.suggestion.default.critical', fallback: 'Show critical findings' }, - ], - }, - }, + { provide: AmbientContextService, useValue: ambientContext }, { provide: I18nService, useValue: { @@ -116,7 +125,15 @@ describe('GlobalSearchComponent', () => { component.onQueryChange('a'); await waitForDebounce(); - expect(searchClient.search).toHaveBeenCalledWith('a', undefined); + expect(searchClient.search).toHaveBeenCalledWith( + 'a', + undefined, + 10, + jasmine.objectContaining({ + currentRoute: '/security/triage', + sessionId: 'session-test', + }), + ); }); it('records synthesis analytics when synthesis is present in search response', async () => { @@ -162,12 +179,20 @@ describe('GlobalSearchComponent', () => { searchChatContext.consumeChatToSearch.and.returnValue({ query: 'CVE-2024-21626', domain: 'findings', + action: 'chat_search_related', + entityKey: 'finding:fnd-998', } as any); routerEvents.next(new NavigationEnd(1, '/security/triage', '/security/triage')); fixture.detectChanges(); expect(component.query()).toBe('CVE-2024-21626'); + expect(ambientContext.recordAction).toHaveBeenCalledWith(jasmine.objectContaining({ + action: 'chat_search_related', + source: 'advisory_ai_chat', + domain: 'findings', + entityKey: 'finding:fnd-998', + })); }); it('navigates to assistant host with openChat intent from Ask AI card action', () => { 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 new file mode 100644 index 000000000..a2ca2da23 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts @@ -0,0 +1,226 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + buildResponse, + setupAuthenticatedSession, + setupBasicMocks, + typeInSearch, + waitForEntityCards, + waitForResults, +} from './unified-search-fixtures'; + +const criticalFindingCard = { + entityKey: 'cve:CVE-2024-21626', + entityType: 'finding', + domain: 'findings', + title: 'CVE-2024-21626 in api-gateway', + snippet: 'Reachable critical vulnerability detected in production workload.', + score: 0.96, + severity: 'critical', + actions: [ + { + label: 'Open finding', + actionType: 'navigate', + route: '/security/triage?q=CVE-2024-21626', + isPrimary: true, + }, + ], + sources: ['findings'], + metadata: {}, +}; + +const criticalFindingsResponse = buildResponse( + 'critical findings', + [criticalFindingCard], + { + summary: 'One critical finding matched. Ask AdvisoryAI for triage guidance.', + template: 'finding_overview', + confidence: 'high', + sourceCount: 1, + domainsCovered: ['findings'], + }, +); + +const cveFollowupResponse = buildResponse( + 'CVE-2024-21626', + [criticalFindingCard], + { + summary: 'Follow-up query for CVE-2024-21626 returned one finding.', + template: 'finding_overview', + confidence: 'high', + sourceCount: 1, + domainsCovered: ['findings'], + }, +); + +test.describe('Unified Search - Contextual Suggestions', () => { + test.beforeEach(async ({ page }) => { + await setupBasicMocks(page); + await setupAuthenticatedSession(page); + }); + + test('updates empty-state chips automatically when route changes', async ({ page }) => { + await page.goto('/security/triage'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + + const searchInput = page.locator('app-global-search input[type="text"]'); + await searchInput.focus(); + await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.search__suggestions .search__chip', { + hasText: /critical findings/i, + }).first()).toBeVisible(); + + await page.goto('/ops/policy'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + await searchInput.focus(); + await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.search__suggestions .search__chip', { + hasText: /failing policy gates/i, + }).first()).toBeVisible(); + + await page.goto('/ops/timeline'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + await searchInput.focus(); + await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.search__suggestions .search__chip', { + hasText: /failed deployments/i, + }).first()).toBeVisible(); + }); + + test('promotes follow-up chips from recent search actions on the same page scope', async ({ page }) => { + await page.route('**/search/query**', (route) => { + const payload = route.request().postDataJSON() as { q?: string }; + const query = String(payload?.q ?? ''); + const response = query.toLowerCase().includes('cve-2024-21626') + ? cveFollowupResponse + : criticalFindingsResponse; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }); + }); + + await page.goto('/security/triage'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + + await typeInSearch(page, 'CVE-2024-21626'); + await waitForResults(page); + await waitForEntityCards(page, 1); + + await page.locator('app-entity-card').first().click(); + await expect(page).toHaveURL(/\/security\/triage\?q=CVE-2024-21626/i); + + const searchInput = page.locator('app-global-search input[type="text"]'); + await searchInput.focus(); + await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 }); + + await expect(page.locator('.search__suggestions .search__chip', { + hasText: /follow up:\s*CVE-2024-21626/i, + }).first()).toBeVisible(); + }); + + test('chat search-for-more emits ambient lastAction and route context in follow-up search requests', async ({ + page, + }) => { + await mockChatEndpoints(page); + + const capturedRequests: Array> = []; + await page.route('**/search/query**', (route) => { + const body = route.request().postDataJSON() as Record; + capturedRequests.push(body); + const query = String(body['q'] ?? '').toLowerCase(); + const response = query.includes('critical findings') + ? criticalFindingsResponse + : cveFollowupResponse; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }); + }); + + await page.goto('/security/triage'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + + await typeInSearch(page, 'critical findings'); + await waitForResults(page); + await waitForEntityCards(page, 1); + + await page.locator('.entity-card__action--ask-ai').first().click(); + await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 }); + + await page.locator('.search-more-link').click(); + + await expect.poll(() => + capturedRequests.some((request) => + String(request['q'] ?? '').toLowerCase().includes('cve-2024-21626')), + ).toBe(true); + + const handoffRequest = capturedRequests.find((request) => + String(request['q'] ?? '').toLowerCase().includes('cve-2024-21626')); + const ambient = handoffRequest?.['ambient'] as Record | undefined; + const lastAction = ambient?.['lastAction'] as Record | undefined; + + expect(ambient).toBeDefined(); + expect(String(ambient?.['currentRoute'] ?? '')).toContain('/security/triage'); + expect(lastAction?.['action']).toBe('chat_search_for_more'); + expect(lastAction?.['source']).toBe('advisory_ai_chat'); + expect(lastAction?.['domain']).toBe('findings'); + }); +}); + +async function mockChatEndpoints(page: Page): 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-context-1', + tenantId: 'test-tenant', + userId: 'tester', + context: {}, + turns: [], + createdAt: '2026-02-25T00:00:00.000Z', + updatedAt: '2026-02-25T00:00:00.000Z', + }), + }); + }); + + await page.route('**/api/v1/advisory-ai/conversations/*/turns', async (route) => { + if (route.request().method() !== 'POST') { + return route.continue(); + } + + const ssePayload = [ + 'event: progress', + 'data: {"stage":"searching"}', + '', + 'event: token', + 'data: {"content":"CVE-2024-21626 remains relevant for this finding. "}', + '', + 'event: citation', + 'data: {"type":"finding","path":"CVE-2024-21626","verified":true}', + '', + 'event: done', + 'data: {"turnId":"turn-context-1","groundingScore":0.92}', + '', + ].join('\n'); + + return route.fulfill({ + status: 200, + headers: { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache', + }, + body: ssePayload, + }); + }); +}