Repair search result routing and advisory query ranking
This commit is contained in:
@@ -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.
|
||||||
@@ -145,6 +145,7 @@ Implemented in `src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSea
|
|||||||
- Ranking determinism:
|
- Ranking determinism:
|
||||||
- Freshness boost is disabled by default and only applies when `UnifiedFreshnessBoostEnabled` is explicitly enabled.
|
- 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.
|
- 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:
|
- Query telemetry:
|
||||||
- Unified search emits hashed query telemetry (`SHA-256` query hash, intent, domain weights, latency, top domains) via `IUnifiedSearchTelemetrySink`.
|
- 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.
|
- 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.
|
- Type filter chips.
|
||||||
- Result actions:
|
- Result actions:
|
||||||
- Docs: `Open`.
|
- 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).
|
- Doctor: `Run` (navigate to doctor and copy run command).
|
||||||
- `More` action for "show more like this" local query expansion.
|
- `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.
|
- A shared mode switch (`Find`, `Explain`, `Act`) across search and AdvisoryAI with mode-aware chip ranking and handoff prompts.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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
|
## Target interaction model
|
||||||
### Entry
|
### Entry
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ internal sealed class DomainWeightCalculator
|
|||||||
var hasCve = entities.Any(static e =>
|
var hasCve = entities.Any(static e =>
|
||||||
e.EntityType.Equals("cve", StringComparison.OrdinalIgnoreCase) ||
|
e.EntityType.Equals("cve", StringComparison.OrdinalIgnoreCase) ||
|
||||||
e.EntityType.Equals("ghsa", 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["findings"] += tuning.CveBoostFindings;
|
||||||
weights["vex"] += tuning.CveBoostVex;
|
weights["vex"] += tuning.CveBoostVex;
|
||||||
|
|||||||
@@ -1718,19 +1718,18 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
|||||||
{
|
{
|
||||||
var method = GetMetadataString(metadata, "method") ?? "GET";
|
var method = GetMetadataString(metadata, "method") ?? "GET";
|
||||||
var path = GetMetadataString(metadata, "path") ?? "/";
|
var path = GetMetadataString(metadata, "path") ?? "/";
|
||||||
var service = GetMetadataString(metadata, "service") ?? "unknown";
|
|
||||||
var operationId = GetMetadataString(metadata, "operationId") ?? row.Title;
|
var operationId = GetMetadataString(metadata, "operationId") ?? row.Title;
|
||||||
actions.Add(new EntityCardAction(
|
actions.Add(new EntityCardAction(
|
||||||
"Open",
|
"Copy Curl",
|
||||||
"navigate",
|
|
||||||
$"/ops/integrations?q={Uri.EscapeDataString(operationId)}",
|
|
||||||
null,
|
|
||||||
true));
|
|
||||||
actions.Add(new EntityCardAction(
|
|
||||||
"Curl",
|
|
||||||
"copy",
|
"copy",
|
||||||
null,
|
null,
|
||||||
$"curl -X {method.ToUpperInvariant()} \"$STELLAOPS_API_BASE{path}\"",
|
$"curl -X {method.ToUpperInvariant()} \"$STELLAOPS_API_BASE{path}\"",
|
||||||
|
true));
|
||||||
|
actions.Add(new EntityCardAction(
|
||||||
|
"Copy Operation ID",
|
||||||
|
"copy",
|
||||||
|
null,
|
||||||
|
operationId,
|
||||||
false));
|
false));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ internal static class WeightedRrfFusion
|
|||||||
private const int ReciprocalRankConstant = 60;
|
private const int ReciprocalRankConstant = 60;
|
||||||
private const double EntityProximityBoost = 0.8;
|
private const double EntityProximityBoost = 0.8;
|
||||||
private const double MaxFreshnessBoost = 0.05;
|
private const double MaxFreshnessBoost = 0.05;
|
||||||
|
private const double ApiOperationAdvisoryPenalty = -0.08;
|
||||||
private const int FreshnessDaysCap = 365;
|
private const int FreshnessDaysCap = 365;
|
||||||
|
|
||||||
public static IReadOnlyList<(KnowledgeChunkRow Row, double Score, IReadOnlyDictionary<string, string> Debug)> Fuse(
|
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)
|
? ComputeFreshnessBoost(item.Row, referenceTime ?? DateTimeOffset.UnixEpoch)
|
||||||
: 0d;
|
: 0d;
|
||||||
var popBoost = ComputePopularityBoost(item.Row, popularityMap, popularityBoostWeight);
|
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["entityBoost"] = entityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
item.Debug["contextBoost"] = contextBoost.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["gravityBoost"] = gravityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
item.Debug["freshnessBoost"] = freshnessBoost.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["popularityBoost"] = popBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
item.Debug["resultTypeAdjustment"] = resultTypeAdjustment.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
item.Debug["chunkId"] = item.Row.ChunkId;
|
item.Debug["chunkId"] = item.Row.ChunkId;
|
||||||
return item;
|
return item;
|
||||||
})
|
})
|
||||||
@@ -298,4 +301,66 @@ internal static class WeightedRrfFusion
|
|||||||
var entityKey = entityKeyProp.GetString();
|
var entityKey = entityKeyProp.GetString();
|
||||||
return string.IsNullOrWhiteSpace(entityKey) ? null : entityKey.Trim();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,21 @@ public sealed class QueryUnderstandingTests
|
|||||||
weights["vex"].Should().BeGreaterThan(weights["knowledge"]);
|
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]
|
[Fact]
|
||||||
public void DomainWeightCalculator_boosts_policy_for_policy_query()
|
public void DomainWeightCalculator_boosts_policy_for_policy_query()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.StructuredFields.Should().Contain(f => f.Label == "Summary" && f.Value == "Start a new scan");
|
||||||
card.Preview.Content.Should().Contain("curl");
|
card.Preview.Content.Should().Contain("curl");
|
||||||
card.Preview.Content.Should().Contain("POST");
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -227,6 +227,72 @@ public sealed class WeightedRrfFusionTests
|
|||||||
"when popularity boost is disabled, ranking should match the baseline order");
|
"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(
|
private static KnowledgeChunkRow MakeRow(
|
||||||
string chunkId,
|
string chunkId,
|
||||||
string kind,
|
string kind,
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ const suites = [
|
|||||||
script: 'live-frontdoor-unified-search-route-matrix.mjs',
|
script: 'live-frontdoor-unified-search-route-matrix.mjs',
|
||||||
reportPath: path.join(outputDir, 'live-frontdoor-unified-search-route-matrix.json'),
|
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([
|
const failureCountKeys = new Set([
|
||||||
|
|||||||
@@ -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®ions=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();
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SearchResultSeverity,
|
SearchResultSeverity,
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
} from './search.models';
|
} from './search.models';
|
||||||
|
import { buildDocsRoute } from '../navigation/docs-route';
|
||||||
|
|
||||||
interface AdvisoryKnowledgeSearchRequestDto {
|
interface AdvisoryKnowledgeSearchRequestDto {
|
||||||
q: string;
|
q: string;
|
||||||
@@ -295,7 +296,7 @@ export class SearchClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'docs' && open.docs) {
|
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;
|
return undefined;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { buildDocsRoute } from '../../../core/navigation/docs-route';
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// chat.models.ts
|
// chat.models.ts
|
||||||
// Sprint: SPRINT_20260107_006_003_BE_advisoryai_chat
|
// Sprint: SPRINT_20260107_006_003_BE_advisoryai_chat
|
||||||
@@ -280,7 +282,7 @@ export function getObjectLinkUrl(link: ParsedObjectLink): string {
|
|||||||
case 'auth':
|
case 'auth':
|
||||||
return `/settings/identity-providers?q=${encodeURIComponent(link.path)}`;
|
return `/settings/identity-providers?q=${encodeURIComponent(link.path)}`;
|
||||||
case 'docs':
|
case 'docs':
|
||||||
return `/docs/${encodeURIComponent(link.path)}`;
|
return buildDocsRoute(link.path);
|
||||||
case 'finding':
|
case 'finding':
|
||||||
return `/security/findings/${encodeURIComponent(link.path)}`;
|
return `/security/findings/${encodeURIComponent(link.path)}`;
|
||||||
case 'scan':
|
case 'scan':
|
||||||
|
|||||||
@@ -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]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1521,7 +1521,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
if (value.trim().length === 0) {
|
if (value.trim().length === 0) {
|
||||||
this.refreshSuggestionViability();
|
this.refreshSuggestionViability();
|
||||||
}
|
}
|
||||||
this.searchTerms$.next(value.trim());
|
this.queueSearch(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
@@ -1683,7 +1683,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
queryHint: query,
|
queryHint: query,
|
||||||
});
|
});
|
||||||
this.query.set(query);
|
this.query.set(query);
|
||||||
this.searchTerms$.next(query.trim());
|
this.queueSearch(query);
|
||||||
this.keepSearchSurfaceOpen();
|
this.keepSearchSurfaceOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1703,7 +1703,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
queryHint: example,
|
queryHint: example,
|
||||||
});
|
});
|
||||||
this.query.set(example);
|
this.query.set(example);
|
||||||
this.searchTerms$.next(example.trim());
|
this.queueSearch(example);
|
||||||
this.keepSearchSurfaceOpen();
|
this.keepSearchSurfaceOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1719,7 +1719,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
queryHint: query,
|
queryHint: query,
|
||||||
});
|
});
|
||||||
this.query.set(query);
|
this.query.set(query);
|
||||||
this.searchTerms$.next(query.trim());
|
this.queueSearch(query);
|
||||||
this.keepSearchSurfaceOpen();
|
this.keepSearchSurfaceOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1730,7 +1730,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
queryHint: query,
|
queryHint: query,
|
||||||
});
|
});
|
||||||
this.query.set(query);
|
this.query.set(query);
|
||||||
this.searchTerms$.next(query.trim());
|
this.queueSearch(query);
|
||||||
this.keepSearchSurfaceOpen();
|
this.keepSearchSurfaceOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1741,7 +1741,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
queryHint: text,
|
queryHint: text,
|
||||||
});
|
});
|
||||||
this.query.set(text);
|
this.query.set(text);
|
||||||
this.searchTerms$.next(text.trim());
|
this.queueSearch(text);
|
||||||
this.keepSearchSurfaceOpen();
|
this.keepSearchSurfaceOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1979,11 +1979,19 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
domain: context.domain,
|
domain: context.domain,
|
||||||
entityKey: context.entityKey,
|
entityKey: context.entityKey,
|
||||||
});
|
});
|
||||||
this.searchTerms$.next(query);
|
this.queueSearch(query);
|
||||||
|
|
||||||
setTimeout(() => this.searchInputRef?.nativeElement?.focus(), 0);
|
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() {
|
private buildAmbientSnapshot() {
|
||||||
return this.ambientContext.buildAmbientContext({
|
return this.ambientContext.buildAmbientContext({
|
||||||
visibleEntityKeys: this.filteredCards().map((card) => card.entityKey),
|
visibleEntityKeys: this.filteredCards().map((card) => card.entityKey),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { buildDocsRoute, parseDocsUrl } from '../../core/navigation/docs-route';
|
||||||
|
|
||||||
export interface SearchRouteMatrixEntry {
|
export interface SearchRouteMatrixEntry {
|
||||||
domain: 'knowledge' | 'findings' | 'policy' | 'vex' | 'platform';
|
domain: 'knowledge' | 'findings' | 'policy' | 'vex' | 'platform';
|
||||||
sourceRoute: string;
|
sourceRoute: string;
|
||||||
@@ -11,6 +13,11 @@ export const SEARCH_ACTION_ROUTE_MATRIX: ReadonlyArray<SearchRouteMatrixEntry> =
|
|||||||
sourceRoute: '/docs/modules/platform/architecture-overview.md#release-flow',
|
sourceRoute: '/docs/modules/platform/architecture-overview.md#release-flow',
|
||||||
expectedRoute: '/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',
|
domain: 'findings',
|
||||||
sourceRoute: '/triage/findings/fnd-123',
|
sourceRoute: '/triage/findings/fnd-123',
|
||||||
@@ -61,6 +68,11 @@ export function normalizeSearchActionRoute(route: string): string {
|
|||||||
|
|
||||||
const pathname = parsedUrl.pathname;
|
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/')) {
|
if (pathname.startsWith('/triage/findings/')) {
|
||||||
parsedUrl.pathname = `/security/findings/${pathname.substring('/triage/findings/'.length)}`;
|
parsedUrl.pathname = `/security/findings/${pathname.substring('/triage/findings/'.length)}`;
|
||||||
} else if (pathname.startsWith('/vex-hub/')) {
|
} else if (pathname.startsWith('/vex-hub/')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user