context detemrinistic + randomized searches and fix for setup from stella-ops.local rather 127.1.0.*
This commit is contained in:
@@ -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