context detemrinistic + randomized searches and fix for setup from stella-ops.local rather 127.1.0.*
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
45
docs/modules/ui/search-chip-context-contract.md
Normal file
45
docs/modules/ui/search-chip-context-contract.md
Normal file
@@ -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).
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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<UnifiedSearchResponse> {
|
||||
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 '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, readonly ContextSuggestion[]>;
|
||||
|
||||
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<string, readonly ContextSuggestion[]>;
|
||||
private readonly actionHistoryByScope = signal<Record<string, UnifiedSearchAmbientAction[]>>({});
|
||||
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<string, number>();
|
||||
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<string, UnifiedSearchAmbientAction[]> = { ...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<string>();
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ChatToSearchContext {
|
||||
query: string;
|
||||
domain?: UnifiedSearchDomain;
|
||||
entityKey?: string;
|
||||
action?: 'chat_search_for_more' | 'chat_search_related';
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<ChatMessageComponent>;
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('GlobalSearchComponent', () => {
|
||||
let fixture: ComponentFixture<GlobalSearchComponent>;
|
||||
let component: GlobalSearchComponent;
|
||||
let searchClient: jasmine.SpyObj<UnifiedSearchClient>;
|
||||
let ambientContext: jasmine.SpyObj<AmbientContextService>;
|
||||
let routerEvents: Subject<unknown>;
|
||||
let router: { url: string; events: Subject<unknown>; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy };
|
||||
let searchChatContext: jasmine.SpyObj<SearchChatContextService>;
|
||||
@@ -49,6 +50,24 @@ describe('GlobalSearchComponent', () => {
|
||||
}));
|
||||
searchClient.getHistory.and.returnValue(of([]));
|
||||
|
||||
ambientContext = jasmine.createSpyObj('AmbientContextService', [
|
||||
'buildContextFilter',
|
||||
'getSearchSuggestions',
|
||||
'buildAmbientContext',
|
||||
'recordAction',
|
||||
]) as jasmine.SpyObj<AmbientContextService>;
|
||||
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', () => {
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
await page.route('**/search/query**', (route) => {
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
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<string, unknown> | undefined;
|
||||
const lastAction = ambient?.['lastAction'] as Record<string, unknown> | 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<void> {
|
||||
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
conversationId: 'conv-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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user