Repair search result routing and advisory query ranking

This commit is contained in:
master
2026-03-12 11:57:40 +02:00
parent 6964a046a5
commit 29b68f5bee
17 changed files with 945 additions and 20 deletions

View File

@@ -0,0 +1,84 @@
# Sprint 20260312_001 - Platform Search Result Action And Canonical Route Repair
## Topic & Scope
- Expand live search verification beyond starter-chip execution into direct typed-query result actions, because user-reported `cve` searches still exposed broken or misleading result behavior.
- Repair the search slice as one product surface: query-entry loading state, advisory-vs-API ranking, API-card action semantics, and canonical docs navigation.
- Rebuild the touched backend and web surfaces, rerun the live Playwright sweep on the real frontdoor, and only then close the iteration with a local commit.
- Working directory: `.`.
- Expected evidence: focused AdvisoryAI/Web tests, live Playwright result-action sweep output, rebuilt live stack proof, and updated search docs.
## Dependencies & Concurrency
- Depends on the clean scratch iteration baseline in `docs/implplan/SPRINT_20260311_014_Platform_scratch_iteration_003_full_route_action_audit.md`.
- Safe parallelism: scoped to AdvisoryAI unified-search, web global-search, live Playwright scripts, and documentation updates for search behavior.
## Documentation Prerequisites
- `AGENTS.md`
- `docs/qa/feature-checks/FLOW.md`
- `docs/modules/ui/search-zero-learning-primary-entry.md`
- `docs/modules/advisory-ai/knowledge-search.md`
- `src/AdvisoryAI/__Tests/INFRASTRUCTURE.md`
## Delivery Tracker
### PLATFORM-SEARCH-001 - Capture the real live failures on direct search result actions
Status: DONE
Dependency: none
Owners: QA
Task description:
- Run direct typed-query Playwright verification against the live authenticated frontdoor instead of relying only on suggestion-chip coverage.
- The sweep must exercise generic advisory intent and explicit API intent, record which cards appear first, and verify that result actions lead to the expected product surfaces.
Completion criteria:
- [x] Live typed-query result evidence is captured.
- [x] The sweep distinguishes product defects from harness false positives.
- [x] The failing behaviors are enumerated before fixes begin.
### PLATFORM-SEARCH-002 - Root-cause and implement the clean search-slice repair
Status: DONE
Dependency: PLATFORM-SEARCH-001
Owners: 3rd line support, Product Manager, Architect, Developer
Task description:
- Repair the live search slice without quick fixes. The chosen solution must preserve search-first operator behavior and avoid dead-end or recovery-only routes.
- Expected repair areas include query-entry state, ranking semantics, card-action contracts, and canonical route normalization where the live shell still leaks encoded or placeholder routes.
Completion criteria:
- [x] Generic advisory queries prefer findings/VEX over API-operation cards unless the query explicitly asks for API details.
- [x] API cards use truthful copy-first actions instead of dead-end navigation.
- [x] Global-search action routing canonicalizes docs targets instead of navigating through double-encoded recovery paths.
- [x] Focused frontend/backend tests cover the repaired behavior.
### PLATFORM-SEARCH-003 - Rebuild, redeploy, and reverify the live search slice
Status: DOING
Dependency: PLATFORM-SEARCH-002
Owners: QA, Developer
Task description:
- Rebuild the touched AdvisoryAI and web surfaces, redeploy them to the live compose stack, and rerun the live search result-action sweep plus the aggregate route/action audit.
Completion criteria:
- [x] AdvisoryAI targeted verification passes with project-compliant targeting.
- [x] Web targeted verification passes.
- [x] Live Playwright search result-action evidence is clean after redeploy.
- [ ] The iteration is committed locally with docs updated.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-12 | Sprint created after user-reported direct search failures showed the existing starter-chip matrix was not enough. | QA |
| 2026-03-12 | Added a live direct search result-action sweep and captured the real before-state: generic `cve` searches on the live stack still needed grouped verification for ranking, action handoffs, and docs-route canonicalization. The first sweep also exposed harness false positives around route-local result expectations and docs-shell async hydration. | QA / 3rd line support |
| 2026-03-12 | Root-caused the user-visible search defects to a product split: query-entry loading was not set until after debounce, generic advisory keywords could still underweight findings/VEX when no full CVE token was detected, API operation cards still depended on misleading result semantics, and docs result actions were allowed to navigate to double-encoded recovery URLs. | 3rd line support / Architect |
| 2026-03-12 | Began the clean repair: global-search now enters loading immediately on non-empty queries, direct search QA now distinguishes route-local expectations from global expectations, and docs-action normalization plus search-result routing coverage were added so canonical routes can be verified before commit. | Developer |
| 2026-03-12 | Reverified the focused repair layers before deploy: `GlobalSearchComponent` + `search-route-matrix` specs passed `4/4`, and xUnit v3 class-targeted AdvisoryAI runs passed `28/28` (`QueryUnderstandingTests`), `10/10` (`WeightedRrfFusionTests`), and `45/45` (`UnifiedSearchServiceTests`). | QA / Developer |
| 2026-03-12 | Rebuilt `stellaops/advisory-ai-web:dev` and `stellaops/advisory-ai-worker:dev`, rebuilt the web bundle, redeployed both AdvisoryAI services, synced the new browser dist into `compose_console-dist`, and restarted `stellaops-router-gateway`. AdvisoryAI startup rebuild converged cleanly with `documents=470`, `chunks=9051`, `api_operations=2190`, `doctor_projections=8`. | Developer / 3rd line support |
| 2026-03-12 | Live direct search result-action verification is now clean on the rebuilt stack: `failedCheckCount=0`, `runtimeIssueCount=0` in `src/Web/StellaOps.Web/output/playwright/live-search-result-action-sweep.json`, with generic `cve` queries grounding into Findings/VEX plus canonical docs navigation and explicit API-intent queries surfacing copy-first API cards. | QA |
| 2026-03-12 | Started the aggregate live audit rerun with the new search-result suite included. The 111-route canonical sweep has already completed cleanly (`passedRoutes=111`, `failedRoutes=[]`) and downstream action-suite reruns are still in progress. | QA |
## Decisions & Risks
- Decision: direct typed-query result actions are now part of the search release gate. Starter-chip execution alone is insufficient because it misses ranking/order and result-card action defects.
- Decision: generic advisory/security-id intent must favor operator evidence over raw API references. API cards remain discoverable for explicit API-intent queries instead of polluting default advisory searches.
- Decision: canonical route normalization is a product requirement. A docs page that recovers from `/docs/docs%2F...` is still considered broken behavior until search emits or normalizes the correct route.
- Risk: several search-related source changes were already present locally and partially live from prior rebuilds without a commit. This sprint audits and formalizes that work instead of treating the dirty tree as trustworthy by default.
## Next Checkpoints
- Finish the route-normalization and search-sweep repair.
- Rebuild `advisory-ai-web` and the web bundle on the live stack.
- Rerun the direct search result-action sweep and fold it into the full live audit.

View File

@@ -145,6 +145,7 @@ Implemented in `src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSea
- Ranking determinism:
- Freshness boost is disabled by default and only applies when `UnifiedFreshnessBoostEnabled` is explicitly enabled.
- Ranking no longer depends on ambient wall-clock time unless that option is enabled.
- Generic advisory/security-id intent (`cve`, `ghsa`, vulnerability/advisory wording) boosts findings and VEX evidence ahead of API-operation knowledge cards unless the query explicitly asks for API/endpoint material.
- Query telemetry:
- Unified search emits hashed query telemetry (`SHA-256` query hash, intent, domain weights, latency, top domains) via `IUnifiedSearchTelemetrySink`.
- Search analytics persistence stores hashed query keys (`SHA-256`, normalized) and pseudonymous user keys (tenant+user hash) in analytics/feedback artifacts.
@@ -180,7 +181,7 @@ Global search now consumes AKS and supports:
- Type filter chips.
- Result actions:
- Docs: `Open`.
- API: `Curl` (copy command).
- API: `Copy Curl` plus `Copy Operation ID`; API cards must not navigate to dead-end placeholder routes.
- Doctor: `Run` (navigate to doctor and copy run command).
- `More` action for "show more like this" local query expansion.
- A shared mode switch (`Find`, `Explain`, `Act`) across search and AdvisoryAI with mode-aware chip ranking and handoff prompts.

View File

@@ -41,6 +41,8 @@
10. Search should summarize close evidence automatically. AdvisoryAI expands detail; it should not be required to make the primary result understandable.
11. Search suggestions are only valid when end-to-end execution against the active ingested corpus returns a grounded or materially useful answer.
12. Search verification is incomplete until live ingestion-backed route coverage proves the surfaced suggestions on every supported page family.
13. Generic advisory queries such as `cve` or `ghsa` must rank operator-facing findings/VEX evidence before raw API operation cards unless the query explicitly asks for API/endpoint details.
14. Search actions must land on canonical Stella routes. Recovery-only URLs such as double-encoded docs paths are not release-ready behavior.
## Target interaction model
### Entry

View File

@@ -40,8 +40,10 @@ internal sealed class DomainWeightCalculator
var hasCve = entities.Any(static e =>
e.EntityType.Equals("cve", StringComparison.OrdinalIgnoreCase) ||
e.EntityType.Equals("ghsa", StringComparison.OrdinalIgnoreCase));
var mentionsSecurityIdFamily = query.Contains("cve", StringComparison.OrdinalIgnoreCase) ||
query.Contains("ghsa", StringComparison.OrdinalIgnoreCase);
if (hasCve)
if (hasCve || mentionsSecurityIdFamily)
{
weights["findings"] += tuning.CveBoostFindings;
weights["vex"] += tuning.CveBoostVex;

View File

@@ -1718,19 +1718,18 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
{
var method = GetMetadataString(metadata, "method") ?? "GET";
var path = GetMetadataString(metadata, "path") ?? "/";
var service = GetMetadataString(metadata, "service") ?? "unknown";
var operationId = GetMetadataString(metadata, "operationId") ?? row.Title;
actions.Add(new EntityCardAction(
"Open",
"navigate",
$"/ops/integrations?q={Uri.EscapeDataString(operationId)}",
null,
true));
actions.Add(new EntityCardAction(
"Curl",
"Copy Curl",
"copy",
null,
$"curl -X {method.ToUpperInvariant()} \"$STELLAOPS_API_BASE{path}\"",
true));
actions.Add(new EntityCardAction(
"Copy Operation ID",
"copy",
null,
operationId,
false));
break;
}

View File

@@ -7,6 +7,7 @@ internal static class WeightedRrfFusion
private const int ReciprocalRankConstant = 60;
private const double EntityProximityBoost = 0.8;
private const double MaxFreshnessBoost = 0.05;
private const double ApiOperationAdvisoryPenalty = -0.08;
private const int FreshnessDaysCap = 365;
public static IReadOnlyList<(KnowledgeChunkRow Row, double Score, IReadOnlyDictionary<string, string> Debug)> Fuse(
@@ -67,12 +68,14 @@ internal static class WeightedRrfFusion
? ComputeFreshnessBoost(item.Row, referenceTime ?? DateTimeOffset.UnixEpoch)
: 0d;
var popBoost = ComputePopularityBoost(item.Row, popularityMap, popularityBoostWeight);
item.Score += entityBoost + contextBoost + gravityBoost + freshnessBoost + popBoost;
var resultTypeAdjustment = ComputeResultTypeAdjustment(item.Row, query, detectedEntities);
item.Score += entityBoost + contextBoost + gravityBoost + freshnessBoost + popBoost + resultTypeAdjustment;
item.Debug["entityBoost"] = entityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["contextBoost"] = contextBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["gravityBoost"] = gravityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["freshnessBoost"] = freshnessBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["popularityBoost"] = popBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["resultTypeAdjustment"] = resultTypeAdjustment.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
item.Debug["chunkId"] = item.Row.ChunkId;
return item;
})
@@ -298,4 +301,66 @@ internal static class WeightedRrfFusion
var entityKey = entityKeyProp.GetString();
return string.IsNullOrWhiteSpace(entityKey) ? null : entityKey.Trim();
}
private static double ComputeResultTypeAdjustment(
KnowledgeChunkRow row,
string query,
IReadOnlyList<EntityMention>? detectedEntities)
{
if (!string.Equals(row.Kind, "api_operation", StringComparison.OrdinalIgnoreCase))
{
return 0d;
}
if (!IsAdvisoryIntentQuery(query, detectedEntities) || IsExplicitApiIntentQuery(query))
{
return 0d;
}
return ApiOperationAdvisoryPenalty;
}
private static bool IsAdvisoryIntentQuery(
string query,
IReadOnlyList<EntityMention>? detectedEntities)
{
if (detectedEntities?.Any(static entity =>
string.Equals(entity.EntityType, "cve", StringComparison.OrdinalIgnoreCase) ||
string.Equals(entity.EntityType, "ghsa", StringComparison.OrdinalIgnoreCase)) == true)
{
return true;
}
if (string.IsNullOrWhiteSpace(query))
{
return false;
}
return query.Contains("cve", StringComparison.OrdinalIgnoreCase)
|| query.Contains("ghsa", StringComparison.OrdinalIgnoreCase)
|| query.Contains("vulnerability", StringComparison.OrdinalIgnoreCase)
|| query.Contains("advisory", StringComparison.OrdinalIgnoreCase)
|| query.Contains("cvss", StringComparison.OrdinalIgnoreCase)
|| query.Contains("epss", StringComparison.OrdinalIgnoreCase)
|| query.Contains("kev", StringComparison.OrdinalIgnoreCase);
}
private static bool IsExplicitApiIntentQuery(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return false;
}
return query.Contains("/api/", StringComparison.OrdinalIgnoreCase)
|| query.Contains("curl", StringComparison.OrdinalIgnoreCase)
|| query.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|| query.Contains("operation", StringComparison.OrdinalIgnoreCase)
|| query.Contains("route", StringComparison.OrdinalIgnoreCase)
|| query.Contains("get ", StringComparison.OrdinalIgnoreCase)
|| query.Contains("post ", StringComparison.OrdinalIgnoreCase)
|| query.Contains("put ", StringComparison.OrdinalIgnoreCase)
|| query.Contains("patch ", StringComparison.OrdinalIgnoreCase)
|| query.Contains("delete ", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -133,6 +133,21 @@ public sealed class QueryUnderstandingTests
weights["vex"].Should().BeGreaterThan(weights["knowledge"]);
}
[Fact]
public void DomainWeightCalculator_boosts_generic_cve_keyword_queries()
{
var extractor = new EntityExtractor();
var classifier = new IntentClassifier();
var calculator = new DomainWeightCalculator(extractor, classifier, Options.Create(new KnowledgeSearchOptions()));
var entities = extractor.Extract("cve");
var weights = calculator.ComputeWeights("cve", entities, null);
weights["findings"].Should().BeGreaterThan(weights["knowledge"]);
weights["vex"].Should().BeGreaterThan(weights["knowledge"]);
weights["graph"].Should().BeGreaterThan(weights["knowledge"]);
}
[Fact]
public void DomainWeightCalculator_boosts_policy_for_policy_query()
{

View File

@@ -1383,6 +1383,13 @@ public sealed class UnifiedSearchServiceTests
card.Preview.StructuredFields.Should().Contain(f => f.Label == "Summary" && f.Value == "Start a new scan");
card.Preview.Content.Should().Contain("curl");
card.Preview.Content.Should().Contain("POST");
card.Actions.Should().HaveCountGreaterThanOrEqualTo(2);
card.Actions[0].Label.Should().Be("Copy Curl");
card.Actions[0].ActionType.Should().Be("copy");
card.Actions[0].Route.Should().BeNull();
card.Actions[0].Command.Should().Contain("/api/v1/scanner/scans");
card.Actions[1].Label.Should().Be("Copy Operation ID");
card.Actions[1].Command.Should().Be("createScan");
}
[Fact]

View File

@@ -227,6 +227,72 @@ public sealed class WeightedRrfFusionTests
"when popularity boost is disabled, ranking should match the baseline order");
}
[Fact]
public void Fuse_demotes_api_operations_for_advisory_intent_queries()
{
var weights = new Dictionary<string, double>
{
["knowledge"] = 1.0,
["findings"] = 1.0
};
using var apiMetadata = JsonDocument.Parse("""{"domain":"knowledge","entity_key":"api:search"}""");
using var findingMetadata = JsonDocument.Parse("""{"domain":"findings","cveId":"CVE-2026-1234"}""");
var apiRow = MakeRow("chunk-api", "api_operation", "GET /api/v1/search/query", apiMetadata);
var findingRow = MakeRow("chunk-finding", "finding", "CVE-2026-1234 finding", findingMetadata);
var lexical = new Dictionary<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(StringComparer.Ordinal)
{
["chunk-api"] = ("chunk-api", 1, apiRow),
["chunk-finding"] = ("chunk-finding", 2, findingRow)
};
var result = WeightedRrfFusion.Fuse(
weights,
lexical,
[],
"cve",
null);
result.Should().HaveCount(2);
result[0].Row.ChunkId.Should().Be("chunk-finding");
result.Single(item => item.Row.ChunkId == "chunk-api").Debug["resultTypeAdjustment"].Should().NotBe("0.000000");
}
[Fact]
public void Fuse_keeps_api_operations_ranked_for_explicit_api_queries()
{
var weights = new Dictionary<string, double>
{
["knowledge"] = 1.0,
["findings"] = 1.0
};
using var apiMetadata = JsonDocument.Parse("""{"domain":"knowledge","entity_key":"api:search"}""");
using var findingMetadata = JsonDocument.Parse("""{"domain":"findings","cveId":"CVE-2026-1234"}""");
var apiRow = MakeRow("chunk-api", "api_operation", "GET /api/v1/search/query", apiMetadata);
var findingRow = MakeRow("chunk-finding", "finding", "CVE-2026-1234 finding", findingMetadata);
var lexical = new Dictionary<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(StringComparer.Ordinal)
{
["chunk-api"] = ("chunk-api", 1, apiRow),
["chunk-finding"] = ("chunk-finding", 2, findingRow)
};
var result = WeightedRrfFusion.Fuse(
weights,
lexical,
[],
"cve api endpoint",
null);
result.Should().HaveCount(2);
result[0].Row.ChunkId.Should().Be("chunk-api");
result.Single(item => item.Row.ChunkId == "chunk-api").Debug["resultTypeAdjustment"].Should().Be("0.000000");
}
private static KnowledgeChunkRow MakeRow(
string chunkId,
string kind,

View File

@@ -107,6 +107,11 @@ const suites = [
script: 'live-frontdoor-unified-search-route-matrix.mjs',
reportPath: path.join(outputDir, 'live-frontdoor-unified-search-route-matrix.json'),
},
{
name: 'search-result-action-sweep',
script: 'live-search-result-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-search-result-action-sweep.json'),
},
];
const failureCountKeys = new Set([

View File

@@ -0,0 +1,493 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-search-result-action-sweep.json');
const authStatePath = path.join(outputDir, 'live-search-result-action-sweep.state.json');
const authReportPath = path.join(outputDir, 'live-search-result-action-sweep.auth.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
const searchContexts = [
{
label: 'mission-board-cve',
route: '/mission-control/board',
query: 'cve',
requireFindingAction: true,
requireVexAction: true,
requireKnowledgeAction: true,
requireKnowledgeCards: true,
requireVexCards: true,
requireCanonicalDocsRoute: true,
},
{
label: 'triage-cve',
route: '/security/triage?pivot=cve',
query: 'cve',
requireFindingAction: true,
requireVexAction: false,
requireKnowledgeAction: false,
requireKnowledgeCards: false,
requireVexCards: false,
requireCanonicalDocsRoute: false,
},
{
label: 'mission-board-api-operation',
route: '/mission-control/board',
query: 'scanner scans api',
requireFindingAction: false,
requireVexAction: false,
requireKnowledgeAction: false,
requireKnowledgeCards: false,
requireVexCards: false,
requireCanonicalDocsRoute: false,
requireApiCopyCard: true,
},
];
function buildUrl(route) {
const separator = route.includes('?') ? '&' : '?';
return `${baseUrl}${route}${separator}${scopeQuery}`;
}
async function settle(page, ms = 1500) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(ms);
}
async function snapshot(page, label) {
const domSnapshot = await page.evaluate(() => {
const heading =
document.querySelector('h1, h2, [data-testid="page-title"], .page-title')?.textContent ?? '';
const alerts = Array.from(
document.querySelectorAll('[role="alert"], .alert, .error-banner, .success-banner, .loading-text, .search__loading, .search__empty'),
)
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.slice(0, 10);
return {
title: document.title ?? '',
heading: heading.replace(/\s+/g, ' ').trim(),
alerts,
};
}).catch(() => ({
title: '',
heading: '',
alerts: [],
}));
return {
label,
url: page.url(),
title: domSnapshot.title,
heading: domSnapshot.heading,
alerts: domSnapshot.alerts,
};
}
async function waitForDestinationContent(page) {
await settle(page, 1500);
if (!page.url().includes('/docs/')) {
return;
}
await page.waitForFunction(
() => {
const main = document.querySelector('main');
return typeof main?.textContent === 'string' && main.textContent.replace(/\s+/g, ' ').trim().length > 64;
},
undefined,
{ timeout: 10_000 },
).catch(() => {});
}
async function waitForSearchResolution(page, timeoutMs = 15_000) {
const startedAt = Date.now();
let sawLoading = false;
while (Date.now() - startedAt < timeoutMs) {
const state = await page.evaluate(() => ({
cardCount: document.querySelectorAll('.entity-card').length,
loadingVisible: Array.from(document.querySelectorAll('.search__loading'))
.some((node) => (node.textContent || '').trim().length > 0),
emptyTexts: Array.from(document.querySelectorAll('.search__empty, .search__empty-state-copy'))
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.filter(Boolean),
})).catch(() => ({
cardCount: 0,
loadingVisible: false,
emptyTexts: [],
}));
sawLoading ||= state.loadingVisible;
if (state.cardCount > 0) {
return {
resolved: 'cards',
sawLoading,
cardCount: state.cardCount,
emptyTexts: state.emptyTexts,
waitedMs: Date.now() - startedAt,
};
}
if (!state.loadingVisible && state.emptyTexts.length > 0) {
return {
resolved: 'empty',
sawLoading,
cardCount: 0,
emptyTexts: state.emptyTexts,
waitedMs: Date.now() - startedAt,
};
}
await page.waitForTimeout(250);
}
return {
resolved: 'timeout',
sawLoading,
cardCount: await page.locator('.entity-card').count().catch(() => 0),
emptyTexts: await page.locator('.search__empty, .search__empty-state-copy').evaluateAll(
(nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean),
).catch(() => []),
waitedMs: Date.now() - startedAt,
};
}
async function collectCards(page) {
return page.locator('.entity-card').evaluateAll((nodes) =>
nodes.slice(0, 8).map((node) => ({
title: node.querySelector('.entity-card__title')?.textContent?.trim() || '',
domain: node.querySelector('.entity-card__badge')?.textContent?.trim() || '',
snippet: node.querySelector('.entity-card__snippet')?.textContent?.replace(/\s+/g, ' ').trim() || '',
actions: Array.from(node.querySelectorAll('.entity-card__action')).map((button) => ({
label: button.textContent?.replace(/\s+/g, ' ').trim() || '',
isPrimary: button.classList.contains('entity-card__action--primary'),
})).filter((action) => action.label.length > 0),
})),
).catch(() => []);
}
async function executePrimaryAction(page, predicateLabel) {
const cards = page.locator('.entity-card');
const count = await cards.count().catch(() => 0);
for (let index = 0; index < count; index += 1) {
const card = cards.nth(index);
const domain = await card.locator('.entity-card__badge').textContent().then((text) => text?.trim() || '').catch(() => '');
if (!predicateLabel.test(domain)) {
continue;
}
const actionButton = card.locator('.entity-card__action--primary').first();
const actionLabel = await actionButton.textContent().then((text) => text?.replace(/\s+/g, ' ').trim() || '').catch(() => '');
process.stdout.write(`[live-search-result-action-sweep] click domain=${domain} label="${actionLabel}" index=${index}\n`);
await actionButton.click({ timeout: 10_000 }).catch(() => {});
process.stdout.write(`[live-search-result-action-sweep] clicked domain=${domain} url=${page.url()}\n`);
await waitForDestinationContent(page);
process.stdout.write(`[live-search-result-action-sweep] settled domain=${domain} url=${page.url()}\n`);
return {
matchedDomain: domain,
actionLabel,
url: page.url(),
};
}
return null;
}
async function runSearchContext(page, context) {
const responses = [];
const responseListener = async (response) => {
if (!response.url().includes('/api/v1/search/query')) {
return;
}
try {
const body = await response.json();
responses.push({
status: response.status(),
url: response.url(),
cards: (body.cards ?? []).slice(0, 8).map((card) => ({
title: card.title ?? '',
domain: card.domain ?? '',
actions: (card.actions ?? []).map((action) => ({
label: action.label ?? '',
actionType: action.actionType ?? '',
route: action.route ?? '',
})),
})),
diagnostics: body.diagnostics ?? null,
});
} catch {
responses.push({
status: response.status(),
url: response.url(),
cards: [],
diagnostics: null,
});
}
};
page.on('response', responseListener);
try {
process.stdout.write(`[live-search-result-action-sweep] ${context.label} goto ${context.route}\n`);
await page.goto(buildUrl(context.route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
const input = page.locator('input[aria-label="Global search"]').first();
await input.click({ timeout: 10_000 });
await input.fill(context.query);
const resolution = await waitForSearchResolution(page);
process.stdout.write(`[live-search-result-action-sweep] ${context.label} resolved=${resolution.resolved} cards=${resolution.cardCount} waitedMs=${resolution.waitedMs}\n`);
const cards = await collectCards(page);
const latestResponse = responses.at(-1) ?? null;
const result = {
label: context.label,
route: context.route,
query: context.query,
expectations: {
requireFindingAction: context.requireFindingAction === true,
requireVexAction: context.requireVexAction === true,
requireKnowledgeAction: context.requireKnowledgeAction === true,
requireKnowledgeCards: context.requireKnowledgeCards === true,
requireVexCards: context.requireVexCards === true,
requireCanonicalDocsRoute: context.requireCanonicalDocsRoute === true,
requireApiCopyCard: context.requireApiCopyCard === true,
},
resolution,
cards,
latestResponse,
topCard: cards[0] ?? null,
baseSnapshot: await snapshot(page, `${context.label}:results`),
findingAction: null,
vexAction: null,
knowledgeAction: null,
};
if (cards.length > 0 && context.requireFindingAction) {
process.stdout.write(`[live-search-result-action-sweep] ${context.label} finding-action\n`);
await page.goto(buildUrl(context.route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
await page.locator('input[aria-label="Global search"]').first().click({ timeout: 10_000 });
await page.locator('input[aria-label="Global search"]').first().fill(context.query);
await waitForSearchResolution(page);
result.findingAction = await executePrimaryAction(page, /^Findings$/i);
}
if (cards.length > 0 && context.requireVexAction) {
process.stdout.write(`[live-search-result-action-sweep] ${context.label} vex-action\n`);
await page.goto(buildUrl(context.route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
await page.locator('input[aria-label="Global search"]').first().click({ timeout: 10_000 });
await page.locator('input[aria-label="Global search"]').first().fill(context.query);
await waitForSearchResolution(page);
result.vexAction = await executePrimaryAction(page, /^VEX/i);
}
if (cards.length > 0 && context.requireKnowledgeAction) {
process.stdout.write(`[live-search-result-action-sweep] ${context.label} knowledge-action\n`);
await page.goto(buildUrl(context.route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
await page.locator('input[aria-label="Global search"]').first().click({ timeout: 10_000 });
await page.locator('input[aria-label="Global search"]').first().fill(context.query);
await waitForSearchResolution(page);
result.knowledgeAction = await executePrimaryAction(page, /^Knowledge$/i);
}
return result;
} finally {
page.off('response', responseListener);
}
}
function collectFailures(results) {
const failures = [];
for (const result of results) {
const expectations = result.expectations ?? {};
if (result.resolution.resolved !== 'cards') {
failures.push(`${result.label}: query "${result.query}" did not resolve to visible result cards.`);
continue;
}
if (!result.resolution.sawLoading) {
failures.push(`${result.label}: query "${result.query}" never showed a loading state before results.`);
}
const topDomain = (result.topCard?.domain ?? '').toLowerCase();
const topPrimary = result.topCard?.actions?.find((action) => action.isPrimary)?.label ?? '';
if (result.query === 'cve' && (topDomain.includes('knowledge') || topDomain.includes('api') || /copy curl/i.test(topPrimary))) {
failures.push(`${result.label}: generic "${result.query}" search still ranks a knowledge/API card first (${result.topCard?.title ?? 'unknown'}).`);
}
const findingRoute = result.latestResponse?.cards?.find((card) => card.domain === 'findings')?.actions?.[0]?.route ?? '';
if (expectations.requireFindingAction && !findingRoute.startsWith('/security/triage')) {
failures.push(`${result.label}: findings result is missing the canonical triage route.`);
}
const knowledgeRoute = result.latestResponse?.cards?.find((card) => card.domain === 'knowledge')?.actions?.[0]?.route ?? '';
if (expectations.requireKnowledgeCards && !knowledgeRoute) {
failures.push(`${result.label}: query "${result.query}" did not surface a knowledge result.`);
}
if (expectations.requireVexCards && !result.latestResponse?.cards?.some((card) => card.domain === 'vex')) {
failures.push(`${result.label}: query "${result.query}" did not surface a VEX result.`);
}
if (knowledgeRoute && !knowledgeRoute.startsWith('/docs/')) {
failures.push(`${result.label}: knowledge result route is not a docs route (${knowledgeRoute}).`);
}
if (expectations.requireFindingAction && !result.findingAction?.url?.includes('/security/triage')) {
failures.push(`${result.label}: primary finding action did not land on Security Triage.`);
}
if (expectations.requireVexAction && !result.vexAction?.url?.includes('/security/advisories-vex')) {
failures.push(`${result.label}: primary VEX action did not land on Advisories & VEX.`);
}
if (expectations.requireKnowledgeAction && !result.knowledgeAction?.url?.includes('/docs/')) {
failures.push(`${result.label}: primary knowledge action did not land on Documentation.`);
}
if (expectations.requireCanonicalDocsRoute && result.knowledgeAction?.url?.includes('/docs/docs%2F')) {
failures.push(`${result.label}: primary knowledge action stayed on a non-canonical docs route (${result.knowledgeAction.url}).`);
}
if (expectations.requireApiCopyCard) {
const apiCard = result.latestResponse?.cards?.find((card) =>
card.actions?.[0]?.label === 'Copy Curl');
if (!apiCard) {
failures.push(`${result.label}: explicit API query "${result.query}" did not surface a copy-first API card.`);
} else {
const primaryAction = apiCard.actions[0];
const secondaryAction = apiCard.actions[1];
if (primaryAction.actionType !== 'copy' || primaryAction.route) {
failures.push(`${result.label}: API card primary action is not a pure copy action.`);
}
if (secondaryAction?.label !== 'Copy Operation ID' || secondaryAction.actionType !== 'copy') {
failures.push(`${result.label}: API card secondary action is not "Copy Operation ID".`);
}
}
}
}
return failures;
}
function attachRuntimeListeners(page, runtime) {
page.on('console', (message) => {
if (message.type() === 'error') {
runtime.consoleErrors.push(message.text());
}
});
page.on('pageerror', (error) => {
runtime.pageErrors.push(error.message);
});
page.on('requestfailed', (request) => {
const url = request.url();
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
return;
}
const errorText = request.failure()?.errorText ?? 'unknown';
if (errorText === 'net::ERR_ABORTED') {
return;
}
runtime.requestFailures.push(`${request.method()} ${url} ${errorText}`);
});
page.on('response', (response) => {
const url = response.url();
if (!url.includes('/api/v1/search/query')) {
return;
}
if (response.status() >= 400) {
runtime.responseErrors.push(`${response.status()} ${response.request().method()} ${url}`);
}
});
}
async function main() {
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false' });
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
const runtime = {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
try {
const results = [];
for (const contextConfig of searchContexts) {
process.stdout.write(`[live-search-result-action-sweep] START ${contextConfig.label} query="${contextConfig.query}"\n`);
const page = await context.newPage();
attachRuntimeListeners(page, runtime);
try {
// eslint-disable-next-line no-await-in-loop
results.push(await runSearchContext(page, contextConfig));
} finally {
// eslint-disable-next-line no-await-in-loop
await page.close().catch(() => {});
}
process.stdout.write(`[live-search-result-action-sweep] DONE ${contextConfig.label}\n`);
}
const failures = collectFailures(results);
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
scopeQuery,
results,
runtime,
failedCheckCount: failures.length,
runtimeIssueCount:
runtime.consoleErrors.length +
runtime.pageErrors.length +
runtime.requestFailures.length +
runtime.responseErrors.length,
failures,
ok:
failures.length === 0 &&
runtime.consoleErrors.length === 0 &&
runtime.pageErrors.length === 0 &&
runtime.requestFailures.length === 0 &&
runtime.responseErrors.length === 0,
};
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
if (!report.ok) {
process.exitCode = 1;
}
} finally {
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
}
await main();

View File

@@ -13,6 +13,7 @@ import {
SearchResultSeverity,
ENTITY_TYPE_LABELS,
} from './search.models';
import { buildDocsRoute } from '../navigation/docs-route';
interface AdvisoryKnowledgeSearchRequestDto {
q: string;
@@ -295,7 +296,7 @@ export class SearchClient {
}
if (type === 'docs' && open.docs) {
return `/docs/${encodeURIComponent(open.docs.path)}#${encodeURIComponent(open.docs.anchor)}`;
return buildDocsRoute(open.docs.path, open.docs.anchor);
}
return undefined;

View File

@@ -1,3 +1,5 @@
import { buildDocsRoute } from '../../../core/navigation/docs-route';
// -----------------------------------------------------------------------------
// chat.models.ts
// Sprint: SPRINT_20260107_006_003_BE_advisoryai_chat
@@ -280,7 +282,7 @@ export function getObjectLinkUrl(link: ParsedObjectLink): string {
case 'auth':
return `/settings/identity-providers?q=${encodeURIComponent(link.path)}`;
case 'docs':
return `/docs/${encodeURIComponent(link.path)}`;
return buildDocsRoute(link.path);
case 'finding':
return `/security/findings/${encodeURIComponent(link.path)}`;
case 'scan':

View File

@@ -0,0 +1,148 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, Subject } from 'rxjs';
import { UnifiedSearchClient } from '../../core/api/unified-search.client';
import type { UnifiedSearchResponse } from '../../core/api/unified-search.models';
import { I18nService } from '../../core/i18n';
import { AmbientContextService } from '../../core/services/ambient-context.service';
import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service';
import { SearchChatContextService } from '../../core/services/search-chat-context.service';
import { TelemetryClient } from '../../core/telemetry/telemetry.client';
import { GlobalSearchComponent } from './global-search.component';
@Component({
standalone: true,
template: '',
})
class DummyRouteComponent {}
describe('GlobalSearchComponent', () => {
let fixture: ComponentFixture<GlobalSearchComponent>;
let component: GlobalSearchComponent;
let searchClient: jasmine.SpyObj<UnifiedSearchClient>;
let pendingResponse$: Subject<UnifiedSearchResponse>;
let chatToSearchRequested$: Subject<void>;
beforeEach(async () => {
pendingResponse$ = new Subject<UnifiedSearchResponse>();
chatToSearchRequested$ = new Subject<void>();
searchClient = jasmine.createSpyObj(
'UnifiedSearchClient',
['search', 'submitFeedback', 'getHistory', 'recordAnalytics', 'evaluateSuggestions', 'clearHistory'],
) as jasmine.SpyObj<UnifiedSearchClient>;
searchClient.search.and.returnValue(pendingResponse$.asObservable());
searchClient.getHistory.and.returnValue(of([]));
searchClient.evaluateSuggestions.and.returnValue(of({ suggestions: [], coverage: null }));
const ambientContext = jasmine.createSpyObj(
'AmbientContextService',
[
'getSearchContextPanel',
'getSearchSuggestions',
'getCommonQuestions',
'getClarifyingQuestions',
'buildAmbientContext',
'recordAction',
],
) as jasmine.SpyObj<AmbientContextService>;
ambientContext.getSearchContextPanel.and.returnValue(null);
ambientContext.getSearchSuggestions.and.returnValue([]);
ambientContext.getCommonQuestions.and.returnValue([]);
ambientContext.getClarifyingQuestions.and.returnValue([]);
ambientContext.buildAmbientContext.and.returnValue({});
const searchChatContext = {
chatToSearchRequested$,
consumeChatToSearch: jasmine.createSpy('consumeChatToSearch').and.returnValue(null),
setSearchToChat: jasmine.createSpy('setSearchToChat'),
} as unknown as SearchChatContextService;
const assistantDrawer = jasmine.createSpyObj('SearchAssistantDrawerService', ['open']) as unknown as SearchAssistantDrawerService;
const i18n = jasmine.createSpyObj('I18nService', ['tryT']) as unknown as jasmine.SpyObj<I18nService>;
i18n.tryT.and.returnValue(null);
const telemetry = jasmine.createSpyObj('TelemetryClient', ['emit']) as unknown as TelemetryClient;
await TestBed.configureTestingModule({
imports: [GlobalSearchComponent],
providers: [
provideRouter([{ path: '**', component: DummyRouteComponent }]),
{ provide: UnifiedSearchClient, useValue: searchClient },
{ provide: AmbientContextService, useValue: ambientContext },
{ provide: SearchChatContextService, useValue: searchChatContext },
{ provide: SearchAssistantDrawerService, useValue: assistantDrawer },
{ provide: I18nService, useValue: i18n },
{ provide: TelemetryClient, useValue: telemetry },
],
}).compileComponents();
fixture = TestBed.createComponent(GlobalSearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
pendingResponse$.complete();
chatToSearchRequested$.complete();
});
it('shows loading immediately for a typed query instead of a premature empty state', () => {
component.onFocus();
component.onQueryChange('cve');
fixture.detectChanges();
expect(component.isLoading()).toBeTrue();
expect(fixture.nativeElement.querySelector('.search__loading')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.search__empty')).toBeNull();
});
it('replaces the loading state with results once the debounced search resolves', async () => {
const response: UnifiedSearchResponse = {
query: 'cve',
topK: 10,
cards: [
{
entityKey: 'finding-0001',
entityType: 'finding',
domain: 'findings',
title: 'CVE-2026-2222 - billing-worker [critical]',
snippet: 'Finding summary',
score: 0.98,
actions: [
{
label: 'View Finding',
actionType: 'navigate',
route: '/security/triage?q=CVE-2026-2222',
isPrimary: true,
},
],
sources: ['search'],
},
],
synthesis: null,
diagnostics: {
ftsMatches: 12,
vectorMatches: 12,
entityCardCount: 1,
durationMs: 220,
usedVector: true,
mode: 'hybrid',
},
};
component.onFocus();
component.onQueryChange('cve');
fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve, 250));
pendingResponse$.next(response);
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(component.isLoading()).toBeFalse();
expect(fixture.nativeElement.querySelector('.search__loading')).toBeNull();
expect(fixture.nativeElement.querySelector('.search__empty')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('CVE-2026-2222 - billing-worker [critical]');
});
});

View File

@@ -1521,7 +1521,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
if (value.trim().length === 0) {
this.refreshSuggestionViability();
}
this.searchTerms$.next(value.trim());
this.queueSearch(value);
}
onKeydown(event: KeyboardEvent): void {
@@ -1683,7 +1683,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
queryHint: query,
});
this.query.set(query);
this.searchTerms$.next(query.trim());
this.queueSearch(query);
this.keepSearchSurfaceOpen();
}
@@ -1703,7 +1703,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
queryHint: example,
});
this.query.set(example);
this.searchTerms$.next(example.trim());
this.queueSearch(example);
this.keepSearchSurfaceOpen();
}
@@ -1719,7 +1719,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
queryHint: query,
});
this.query.set(query);
this.searchTerms$.next(query.trim());
this.queueSearch(query);
this.keepSearchSurfaceOpen();
}
@@ -1730,7 +1730,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
queryHint: query,
});
this.query.set(query);
this.searchTerms$.next(query.trim());
this.queueSearch(query);
this.keepSearchSurfaceOpen();
}
@@ -1741,7 +1741,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
queryHint: text,
});
this.query.set(text);
this.searchTerms$.next(text.trim());
this.queueSearch(text);
this.keepSearchSurfaceOpen();
}
@@ -1979,11 +1979,19 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
domain: context.domain,
entityKey: context.entityKey,
});
this.searchTerms$.next(query);
this.queueSearch(query);
setTimeout(() => this.searchInputRef?.nativeElement?.focus(), 0);
}
private queueSearch(value: string): void {
const normalized = value.trim();
this.searchResponse.set(null);
this.expandedCardKey.set(null);
this.isLoading.set(normalized.length > 0);
this.searchTerms$.next(normalized);
}
private buildAmbientSnapshot() {
return this.ambientContext.buildAmbientContext({
visibleEntityKeys: this.filteredCards().map((card) => card.entityKey),

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { SEARCH_ACTION_ROUTE_MATRIX, normalizeSearchActionRoute } from './search-route-matrix';
describe('normalizeSearchActionRoute', () => {
it('normalizes the documented route matrix', () => {
for (const entry of SEARCH_ACTION_ROUTE_MATRIX) {
expect(normalizeSearchActionRoute(entry.sourceRoute)).toBe(entry.expectedRoute);
}
});
it('preserves unknown external targets', () => {
expect(normalizeSearchActionRoute('https://example.invalid/docs')).toBe('https://example.invalid/docs');
});
});

View File

@@ -1,3 +1,5 @@
import { buildDocsRoute, parseDocsUrl } from '../../core/navigation/docs-route';
export interface SearchRouteMatrixEntry {
domain: 'knowledge' | 'findings' | 'policy' | 'vex' | 'platform';
sourceRoute: string;
@@ -11,6 +13,11 @@ export const SEARCH_ACTION_ROUTE_MATRIX: ReadonlyArray<SearchRouteMatrixEntry> =
sourceRoute: '/docs/modules/platform/architecture-overview.md#release-flow',
expectedRoute: '/docs/modules/platform/architecture-overview.md#release-flow',
},
{
domain: 'knowledge',
sourceRoute: '/docs/docs%2Fmodules%2Fadvisory-ai%2Fchat-interface.md#vulnerability-investigation',
expectedRoute: '/docs/modules/advisory-ai/chat-interface.md#vulnerability-investigation',
},
{
domain: 'findings',
sourceRoute: '/triage/findings/fnd-123',
@@ -61,6 +68,11 @@ export function normalizeSearchActionRoute(route: string): string {
const pathname = parsedUrl.pathname;
if (pathname.startsWith('/docs/')) {
const docsTarget = parseDocsUrl(`${pathname}${parsedUrl.hash}`);
return buildDocsRoute(docsTarget.path, docsTarget.anchor);
}
if (pathname.startsWith('/triage/findings/')) {
parsedUrl.pathname = `/security/findings/${pathname.substring('/triage/findings/'.length)}`;
} else if (pathname.startsWith('/vex-hub/')) {