context detemrinistic + randomized searches and fix for setup from stella-ops.local rather 127.1.0.*

This commit is contained in:
master
2026-03-06 14:41:05 +02:00
parent 973cc8b335
commit 49763be70b
28 changed files with 1557 additions and 234 deletions

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"

View File

@@ -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.

View File

@@ -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`).

View File

@@ -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`

View File

@@ -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)

View File

@@ -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.
---

View 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).

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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('');

View File

@@ -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 '';

View File

@@ -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;

View File

@@ -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)}`;
}
}

View File

@@ -13,6 +13,7 @@ export interface ChatToSearchContext {
query: string;
domain?: UnifiedSearchDomain;
entityKey?: string;
action?: 'chat_search_for_more' | 'chat_search_related';
}
@Injectable({ providedIn: 'root' })

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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':

View File

@@ -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',
});
});
});

View File

@@ -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');
});
});

View File

@@ -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', () => {

View File

@@ -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,
});
});
}