From 145e67a54449f3ebad3198cbbadabd79941cdb21 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 8 Mar 2026 02:23:43 +0200 Subject: [PATCH] Harden live-backed unified search weighting and indexing --- ...oryAI_search_answer_proof_and_weighting.md | 25 +- docs/API_CLI_REFERENCE.md | 10 +- docs/modules/advisory-ai/knowledge-search.md | 18 +- docs/modules/cli/guides/quickstart.md | 8 +- src/AdvisoryAI/AGENTS.md | 12 +- .../KnowledgeSearch/KnowledgeIndexer.cs | 22 +- ...nowledgeSearchBenchmarkDatasetGenerator.cs | 15 +- .../KnowledgeSearch/KnowledgeSearchOptions.cs | 6 +- .../KnowledgeSearchRepositoryRootResolver.cs | 195 ++++++++++++++ src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md | 2 +- .../Adapters/FindingIngestionAdapter.cs | 81 +++++- .../Adapters/FindingsSearchAdapter.cs | 126 +++++++-- .../Adapters/PolicyRuleIngestionAdapter.cs | 92 ++++++- .../Adapters/PolicySearchAdapter.cs | 135 ++++++++-- .../Adapters/VexSearchAdapter.cs | 92 +++++-- .../Adapters/VexStatementIngestionAdapter.cs | 53 +++- .../Analytics/SearchAnalyticsService.cs | 21 +- .../Context/AmbientContextProcessor.cs | 4 +- .../UnifiedSearch/UnifiedSearchIndexer.cs | 241 ++++++++++++++---- .../UnifiedSearch/UnifiedSearchService.cs | 119 ++++++++- src/AdvisoryAI/__Tests/INFRASTRUCTURE.md | 20 +- ...nifiedSearchLiveAdapterIntegrationTests.cs | 107 ++++++++ .../UnifiedSearchSprintIntegrationTests.cs | 27 ++ ...wledgeSearchRepositoryRootResolverTests.cs | 102 ++++++++ .../UnifiedSearchIngestionAdaptersTests.cs | 134 ++++++++++ .../UnifiedSearchServiceTests.cs | 125 +++++++++ 26 files changed, 1585 insertions(+), 207 deletions(-) create mode 100644 src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchRepositoryRootResolver.cs create mode 100644 src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/KnowledgeSearchRepositoryRootResolverTests.cs diff --git a/docs-archived/implplan/SPRINT_20260307_037_AdvisoryAI_search_answer_proof_and_weighting.md b/docs-archived/implplan/SPRINT_20260307_037_AdvisoryAI_search_answer_proof_and_weighting.md index 6477988eb..07880b6d2 100644 --- a/docs-archived/implplan/SPRINT_20260307_037_AdvisoryAI_search_answer_proof_and_weighting.md +++ b/docs-archived/implplan/SPRINT_20260307_037_AdvisoryAI_search_answer_proof_and_weighting.md @@ -21,7 +21,7 @@ ## Delivery Tracker ### AI-SF-001 - Strengthen automatic in-scope weighting and overflow suppression -Status: TODO +Status: DONE Dependency: none Owners: Developer Task description: @@ -29,12 +29,12 @@ Task description: - Keep overflow only when outside-scope evidence still materially improves the answer. Completion criteria: -- [ ] Service tests cover current-route winners, close-score blends, and suppressed overflow cases. -- [ ] Coverage metadata still explains the winning scope without FE heuristics. -- [ ] Unsupported or weak current-scope corpora do not hide clearly better outside-scope answers. +- [x] Service tests cover current-route winners, close-score blends, and suppressed overflow cases. +- [x] Coverage metadata still explains the winning scope without FE heuristics. +- [x] Unsupported or weak current-scope corpora do not hide clearly better outside-scope answers. ### AI-SF-002 - Make blended answers and suggestion viability stricter -Status: TODO +Status: DONE Dependency: AI-SF-001 Owners: Developer Task description: @@ -42,12 +42,12 @@ Task description: - Keep suggestion viability grounded-only for surfaced chips and detect unsupported/empty corpora explicitly. Completion criteria: -- [ ] Blended and dominant answer paths are tested separately. -- [ ] Clarify-only or unsupported suggestions do not pass visible viability. -- [ ] Corpus-readiness states remain explicit in the response contract. +- [x] Blended and dominant answer paths are tested separately. +- [x] Clarify-only or unsupported suggestions do not pass visible viability. +- [x] Corpus-readiness states remain explicit in the response contract. ### AI-SF-003 - Preserve optional telemetry and deterministic fallbacks -Status: TODO +Status: DONE Dependency: AI-SF-002 Owners: Developer Task description: @@ -55,14 +55,15 @@ Task description: - Ensure the final correction pass does not reintroduce telemetry coupling. Completion criteria: -- [ ] Search behavior remains stable with telemetry disabled. -- [ ] Tests cover telemetry-disabled search and suggestion flows. -- [ ] Docs state clearly that telemetry is optional infrastructure. +- [x] Search behavior remains stable with telemetry disabled. +- [x] Tests cover telemetry-disabled search and suggestion flows. +- [x] Docs state clearly that telemetry is optional infrastructure. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-07 | Sprint created for the backend weighting and suggestion-proof half of the final search-first correction pass. | Project Manager | +| 2026-03-08 | Completed the backend weighting and viability hardening pass: stronger route/action boosts, unified-index FTS/vector persistence, enriched findings/policy/VEX ingestion bodies for route-language recall, and DB-backed similar-query lookup repair. Evidence: `dotnet build "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" --no-restore -v minimal -p:BuildInParallel=false -p:UseSharedCompilation=false`; `StellaOps.AdvisoryAI.Tests.exe -class "StellaOps.AdvisoryAI.Tests.UnifiedSearch.UnifiedSearchServiceTests"` (`45/45`); `StellaOps.AdvisoryAI.Tests.exe -class "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchLiveAdapterIntegrationTests"` (`14/14`); `StellaOps.AdvisoryAI.Tests.exe -class "StellaOps.AdvisoryAI.Tests.UnifiedSearch.SearchAnalyticsServiceTests"` (`1/1`); `StellaOps.AdvisoryAI.Tests.exe -method "StellaOps.AdvisoryAI.Tests.Integration.UnifiedSearchSprintIntegrationTests.G10_SimilarSuccessfulQueries_LoadFromDatabaseWithoutFallingBack"` (`1/1`). | Developer | ## Decisions & Risks - Decision: search answer shape is inferred, not selected by the operator. diff --git a/docs/API_CLI_REFERENCE.md b/docs/API_CLI_REFERENCE.md index 04771a65b..39f4d8b76 100755 --- a/docs/API_CLI_REFERENCE.md +++ b/docs/API_CLI_REFERENCE.md @@ -536,16 +536,16 @@ Use one of these local workflows first: ```bash # Run the CLI directly from source -dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai index rebuild --json +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- --help # Publish a reusable local binary dotnet publish "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -c Release -o ".artifacts/stella-cli" # Windows -.artifacts/stella-cli/StellaOps.Cli.exe advisoryai index rebuild --json +.artifacts/stella-cli/StellaOps.Cli.exe --help # Linux/macOS -./.artifacts/stella-cli/StellaOps.Cli advisoryai index rebuild --json +./.artifacts/stella-cli/StellaOps.Cli --help ``` Related docs: @@ -554,6 +554,10 @@ Related docs: Rebuild the AdvisoryAI deterministic knowledge index from local markdown, OpenAPI specs, and Doctor metadata. +Local source-checkout note: +- `stella advisoryai index rebuild` calls an authenticated backend endpoint. +- For the unauthenticated local live-search verification lane, use `stella advisoryai sources prepare` plus the direct HTTP rebuild calls documented in `src/AdvisoryAI/__Tests/INFRASTRUCTURE.md`. + ### Synopsis ```bash diff --git a/docs/modules/advisory-ai/knowledge-search.md b/docs/modules/advisory-ai/knowledge-search.md index 8bb1053a3..c0bb14746 100644 --- a/docs/modules/advisory-ai/knowledge-search.md +++ b/docs/modules/advisory-ai/knowledge-search.md @@ -358,16 +358,19 @@ docker compose -f devops/compose/docker-compose.advisoryai-knowledge-test.yml ps # Start the local AdvisoryAI service against that database export AdvisoryAI__KnowledgeSearch__ConnectionString="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge" -export AdvisoryAI__KnowledgeSearch__RepositoryRoot="$(pwd)" dotnet run --project "src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj" --no-launch-profile # In a second shell, rebuild the live corpus in the required order export STELLAOPS_BACKEND_URL="http://127.0.0.1:10451" dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai sources prepare --json -dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai index rebuild --json +curl -X POST http://127.0.0.1:10451/v1/advisory-ai/index/rebuild \ + -H "X-StellaOps-Scopes: advisory-ai:admin" \ + -H "X-StellaOps-Tenant: test-tenant" \ + -H "X-StellaOps-Actor: local-search-test" curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \ -H "X-StellaOps-Scopes: advisory-ai:admin" \ - -H "X-StellaOps-Tenant: test-tenant" + -H "X-StellaOps-Tenant: test-tenant" \ + -H "X-StellaOps-Actor: local-search-test" # Run tests with the Live category (requires database) dotnet build "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" -v minimal @@ -375,6 +378,11 @@ src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/bin/Debug/net10.0/StellaOps.Ad -trait "Category=Live" -reporter verbose -noColor ``` +Notes: +- AdvisoryAI knowledge ingestion now auto-detects the repository root from the current working directory and `AppContext.BaseDirectory` when it is running inside a normal Stella Ops source checkout. +- Set `AdvisoryAI__KnowledgeSearch__RepositoryRoot` only when you are running the service from a non-standard layout or a packaged binary tree that is not inside the repository. +- `stella advisoryai index rebuild` and `stella search index rebuild` invoke authenticated backend endpoints. For a local source-checkout verification lane without a signed-in CLI session, use `sources prepare` via CLI and the direct HTTP rebuild calls above with explicit `X-StellaOps-*` headers. + ### CLI setup in a source checkout Do not assume `stella` is already installed on the machine running local AdvisoryAI work. @@ -406,11 +414,11 @@ If the CLI is not built yet, the equivalent HTTP endpoints are: - `POST /v1/search/index/rebuild` for unified overlay domains Current live verification coverage: -- Rebuild order exercised against a running local service: `POST /v1/advisory-ai/index/rebuild` then `POST /v1/search/index/rebuild` +- Rebuild order exercised against a running local service: `POST /v1/advisory-ai/index/rebuild` then `POST /v1/search/index/rebuild`, both with explicit `X-StellaOps-Scopes`, `X-StellaOps-Tenant`, and `X-StellaOps-Actor` headers - Verified live query: `database connectivity` - Verified live outcome: response includes `contextAnswer.status = grounded`, citations, and entity cards over ingested data - Verified live suggestion lane: `src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts` now preflights corpus readiness, validates suggestion viability, executes every surfaced Doctor suggestion, asserts grounded answer states for surfaced live suggestions, verifies follow-up chips after result open, and verifies Ask-AdvisoryAI inherits the live query context -- Verified combined browser gate on 2026-03-07: `20/20` Playwright tests passed across deterministic UX, telemetry-off search flows, self-serve answer panel, and the live suggestion lane against the ingested local corpus +- Verified combined browser gate on 2026-03-08: `24/24` executed tests passed with `3` explicit route-unready skips across deterministic UX, telemetry-off search flows, self-serve answer panel, and the supported-route live suggestion lane against the ingested local corpus - Verified local corpus baseline on 2026-03-07 after `advisoryai sources prepare`: `documentCount = 470`, `chunkCount = 9050`, `apiOperationCount = 2190`, `doctorProjectionCount = 8` - Other routes still rely on deterministic mock-backed Playwright coverage until their ingestion parity is explicitly verified diff --git a/docs/modules/cli/guides/quickstart.md b/docs/modules/cli/guides/quickstart.md index c22919c8e..89cbc5f31 100644 --- a/docs/modules/cli/guides/quickstart.md +++ b/docs/modules/cli/guides/quickstart.md @@ -37,12 +37,16 @@ dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- --help dotnet publish "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -c Release -o ".artifacts/stella-cli" # Windows -.artifacts/stella-cli/StellaOps.Cli.exe advisoryai index rebuild --json +.artifacts/stella-cli/StellaOps.Cli.exe --help # Linux/macOS -./.artifacts/stella-cli/StellaOps.Cli advisoryai index rebuild --json +./.artifacts/stella-cli/StellaOps.Cli --help ``` +For local AdvisoryAI live-search verification from a source checkout: +- use `stella advisoryai sources prepare` from the local CLI build or `dotnet run` +- then use the authenticated HTTP rebuild steps in `src/AdvisoryAI/__Tests/INFRASTRUCTURE.md` + #### Option 1: .NET Tool (Recommended) ```bash diff --git a/src/AdvisoryAI/AGENTS.md b/src/AdvisoryAI/AGENTS.md index 7ebc5ff80..4d2d92e00 100644 --- a/src/AdvisoryAI/AGENTS.md +++ b/src/AdvisoryAI/AGENTS.md @@ -63,8 +63,16 @@ dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.Advisory docker compose -f devops/compose/docker-compose.advisoryai-knowledge-test.yml up -d # Database at localhost:55432, user: stellaops_knowledge, db: advisoryai_knowledge_test # Requires extensions: pgvector, pg_trgm (auto-created by init script) -stella advisoryai sources prepare --json -stella advisoryai index rebuild --json +export STELLAOPS_BACKEND_URL="http://127.0.0.1:10451" +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai sources prepare --json +curl -X POST http://127.0.0.1:10451/v1/advisory-ai/index/rebuild \ + -H "X-StellaOps-Scopes: advisory-ai:admin" \ + -H "X-StellaOps-Tenant: test-tenant" \ + -H "X-StellaOps-Actor: advisoryai-agent" +curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \ + -H "X-StellaOps-Scopes: advisory-ai:admin" \ + -H "X-StellaOps-Tenant: test-tenant" \ + -H "X-StellaOps-Actor: advisoryai-agent" dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj \ --filter "Category=Live" -v normal ``` diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeIndexer.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeIndexer.cs index 8377277cb..60125f513 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeIndexer.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeIndexer.cs @@ -82,11 +82,23 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer private EffectiveIngestionOptions ResolveEffectiveOptions() { - var repositoryRoot = string.IsNullOrWhiteSpace(_options.RepositoryRoot) - ? Directory.GetCurrentDirectory() - : Path.IsPathRooted(_options.RepositoryRoot) - ? Path.GetFullPath(_options.RepositoryRoot) - : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot)); + var repositoryRootResolution = KnowledgeSearchRepositoryRootResolver.Resolve(_options); + if (!repositoryRootResolution.Validated) + { + _logger.LogWarning( + "Knowledge ingestion could not validate repository root from source {Source}; using {RepositoryRoot}. Relative corpus paths may resolve to an empty index.", + repositoryRootResolution.Source, + repositoryRootResolution.Path); + } + else if (!string.Equals(repositoryRootResolution.Source, "configured", StringComparison.Ordinal)) + { + _logger.LogInformation( + "Auto-detected AdvisoryAI repository root from {Source}: {RepositoryRoot}.", + repositoryRootResolution.Source, + repositoryRootResolution.Path); + } + + var repositoryRoot = repositoryRootResolution.Path; var markdownRoots = (_options.MarkdownRoots ?? []) .Where(static root => !string.IsNullOrWhiteSpace(root)) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchBenchmarkDatasetGenerator.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchBenchmarkDatasetGenerator.cs index 89153cc7a..03558af2f 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchBenchmarkDatasetGenerator.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchBenchmarkDatasetGenerator.cs @@ -120,17 +120,16 @@ internal sealed class KnowledgeSearchBenchmarkDatasetGenerator : IKnowledgeSearc private string ResolveRepositoryRoot() { - if (string.IsNullOrWhiteSpace(_options.RepositoryRoot)) + var resolution = KnowledgeSearchRepositoryRootResolver.Resolve(_options); + if (!resolution.Validated) { - return Directory.GetCurrentDirectory(); + _logger.LogWarning( + "Knowledge benchmark dataset generation could not validate repository root from source {Source}; using {RepositoryRoot}.", + resolution.Source, + resolution.Path); } - if (Path.IsPathRooted(_options.RepositoryRoot)) - { - return Path.GetFullPath(_options.RepositoryRoot); - } - - return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot)); + return resolution.Path; } private IReadOnlyList LoadMarkdownTargets(string repositoryRoot, CancellationToken cancellationToken) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs index 63fe52829..5a37ef511 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs @@ -55,13 +55,13 @@ public sealed class KnowledgeSearchOptions public List OpenApiRoots { get; set; } = ["src", "devops/compose"]; public string UnifiedFindingsSnapshotPath { get; set; } = - "UnifiedSearch/Snapshots/findings.snapshot.json"; + "src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/findings.snapshot.json"; public string UnifiedVexSnapshotPath { get; set; } = - "UnifiedSearch/Snapshots/vex.snapshot.json"; + "src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/vex.snapshot.json"; public string UnifiedPolicySnapshotPath { get; set; } = - "UnifiedSearch/Snapshots/policy.snapshot.json"; + "src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/policy.snapshot.json"; public bool UnifiedAutoIndexEnabled { get; set; } diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchRepositoryRootResolver.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchRepositoryRootResolver.cs new file mode 100644 index 000000000..446f7f14c --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchRepositoryRootResolver.cs @@ -0,0 +1,195 @@ +namespace StellaOps.AdvisoryAI.KnowledgeSearch; + +internal sealed record KnowledgeSearchRepositoryRootResolution( + string Path, + bool Validated, + string Source); + +internal static class KnowledgeSearchRepositoryRootResolver +{ + public static string ResolvePath(KnowledgeSearchOptions options, string configuredPath) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(configuredPath); + + if (Path.IsPathRooted(configuredPath)) + { + return Path.GetFullPath(configuredPath); + } + + var repositoryRoot = Resolve(options); + return Path.GetFullPath(Path.Combine(repositoryRoot.Path, configuredPath)); + } + + public static KnowledgeSearchRepositoryRootResolution Resolve(KnowledgeSearchOptions options) + { + return Resolve(options, Directory.GetCurrentDirectory(), AppContext.BaseDirectory); + } + + internal static KnowledgeSearchRepositoryRootResolution Resolve( + KnowledgeSearchOptions options, + string? currentDirectory, + string? appBaseDirectory) + { + ArgumentNullException.ThrowIfNull(options); + + var configuredRoot = NormalizeConfiguredRoot(options.RepositoryRoot, currentDirectory); + var relativeMarkers = BuildRelativeMarkers(options); + + foreach (var (candidate, source, allowAncestorSearch) in EnumerateCandidates(configuredRoot, currentDirectory, appBaseDirectory)) + { + var resolved = FindRepositoryRoot(candidate, relativeMarkers, allowAncestorSearch); + if (resolved is null) + { + continue; + } + + return new KnowledgeSearchRepositoryRootResolution(resolved, true, source); + } + + if (!string.IsNullOrWhiteSpace(configuredRoot)) + { + return new KnowledgeSearchRepositoryRootResolution(configuredRoot, false, "configured_fallback"); + } + + var fallbackPath = + NormalizeAbsolutePath(currentDirectory) + ?? NormalizeAbsolutePath(appBaseDirectory) + ?? Path.GetFullPath(Directory.GetCurrentDirectory()); + + return new KnowledgeSearchRepositoryRootResolution(fallbackPath, false, "current_directory_fallback"); + } + + private static IEnumerable<(string Candidate, string Source, bool AllowAncestorSearch)> EnumerateCandidates( + string? configuredRoot, + string? currentDirectory, + string? appBaseDirectory) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(configuredRoot) && seen.Add(configuredRoot)) + { + yield return (configuredRoot, "configured", false); + } + + var normalizedCurrentDirectory = NormalizeAbsolutePath(currentDirectory); + if (!string.IsNullOrWhiteSpace(normalizedCurrentDirectory) && seen.Add(normalizedCurrentDirectory)) + { + yield return (normalizedCurrentDirectory, "current_directory", true); + } + + var normalizedAppBaseDirectory = NormalizeAbsolutePath(appBaseDirectory); + if (!string.IsNullOrWhiteSpace(normalizedAppBaseDirectory) && seen.Add(normalizedAppBaseDirectory)) + { + yield return (normalizedAppBaseDirectory, "app_base_directory", true); + } + } + + private static string? FindRepositoryRoot(string? startPath, IReadOnlyList relativeMarkers, bool allowAncestorSearch) + { + var normalizedStartPath = NormalizeAbsolutePath(startPath); + if (string.IsNullOrWhiteSpace(normalizedStartPath)) + { + return null; + } + + if (!allowAncestorSearch) + { + return LooksLikeRepositoryRoot(normalizedStartPath, relativeMarkers) + ? normalizedStartPath + : null; + } + + var current = new DirectoryInfo(normalizedStartPath); + while (current is not null) + { + if (LooksLikeRepositoryRoot(current.FullName, relativeMarkers)) + { + return current.FullName; + } + + current = current.Parent; + } + + return null; + } + + private static bool LooksLikeRepositoryRoot(string candidate, IReadOnlyList relativeMarkers) + { + if (!Directory.Exists(candidate)) + { + return false; + } + + var hasRepoShape = + File.Exists(Path.Combine(candidate, "global.json")) + || (Directory.Exists(Path.Combine(candidate, "src")) && Directory.Exists(Path.Combine(candidate, "docs"))); + + if (!hasRepoShape) + { + return false; + } + + foreach (var marker in relativeMarkers) + { + var markerPath = Path.Combine(candidate, marker); + if (Directory.Exists(markerPath) || File.Exists(markerPath)) + { + return true; + } + } + + return false; + } + + private static string? NormalizeConfiguredRoot(string? configuredRoot, string? currentDirectory) + { + if (string.IsNullOrWhiteSpace(configuredRoot)) + { + return null; + } + + if (Path.IsPathRooted(configuredRoot)) + { + return Path.GetFullPath(configuredRoot); + } + + var baseDirectory = NormalizeAbsolutePath(currentDirectory) ?? Path.GetFullPath(Directory.GetCurrentDirectory()); + return Path.GetFullPath(Path.Combine(baseDirectory, configuredRoot)); + } + + private static List BuildRelativeMarkers(KnowledgeSearchOptions options) + { + var markers = new HashSet(StringComparer.OrdinalIgnoreCase); + + AddRelativeMarker(markers, options.MarkdownAllowListPath); + AddRelativeMarker(markers, options.DoctorSeedPath); + AddRelativeMarker(markers, options.DoctorControlsPath); + AddRelativeMarker(markers, options.OpenApiAggregatePath); + AddRelativeMarker(markers, "docs"); + AddRelativeMarker(markers, "src"); + AddRelativeMarker(markers, "devops"); + + return markers.ToList(); + } + + private static void AddRelativeMarker(ISet markers, string? value) + { + if (string.IsNullOrWhiteSpace(value) || Path.IsPathRooted(value)) + { + return; + } + + markers.Add(value.Trim()); + } + + private static string? NormalizeAbsolutePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + return Path.GetFullPath(path); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md index 52daf67c9..42fb6477b 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md @@ -17,7 +17,7 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver | AI-SELF-006 | DONE | Live ingestion-backed answer verification succeeded on the Doctor/knowledge route after local rebuild. | | SPRINT_20260307_019-AI-ZL | DONE | Unified search now applies implicit current-scope weighting, emits additive `overflow`/`coverage`, blends close top answers, and evaluates suggestion viability without requiring telemetry. | | SPRINT_20260307_033-AI-ZL | DONE | Unified search now derives answer blending from query/context, exposes grounded-only suggestion viability with corpus-readiness states, and keeps analytics/feedback telemetry fully optional. | -| SPRINT_20260307_037-AI-SF | TODO | Final search-first correction pass: stronger in-scope weighting, stricter blended-answer thresholds, grounded-only supported-route suggestion viability, and telemetry-independent correctness. | +| SPRINT_20260307_037-AI-SF | DONE | Final search-first correction pass: stronger in-scope weighting, stricter blended-answer thresholds, grounded-only supported-route suggestion viability, and telemetry-independent correctness. | | SPRINT_20260222_051-AKS-INGEST | DONE | Added deterministic AKS ingestion controls: markdown allow-list manifest loading, OpenAPI aggregate source path support, and doctor control projection integration for search chunks, including fallback doctor metadata hydration from controls projection fields. | | AUDIT-0017-M | DONE | Maintainability audit for StellaOps.AdvisoryAI. | | AUDIT-0017-T | DONE | Test coverage audit for StellaOps.AdvisoryAI. | diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingIngestionAdapter.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingIngestionAdapter.cs index 47f4ed464..dc7a86e1e 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingIngestionAdapter.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingIngestionAdapter.cs @@ -68,9 +68,18 @@ internal sealed class FindingIngestionAdapter : ISearchIngestionAdapter var tenant = ReadString(entry, "tenant") ?? "global"; var tags = ReadStringArray(entry, "tags", ["finding", "vulnerability", severity]); - var body = string.IsNullOrWhiteSpace(description) - ? $"{title}\nSeverity: {severity}" - : $"{title}\n{description}\nSeverity: {severity}"; + var body = BuildBody( + findingId, + cveId, + title, + description, + severity, + service, + reachability: null, + environment: null, + product: null, + policyBadge: null, + tags); var chunkId = KnowledgeSearchText.StableId("chunk", "finding", findingId, cveId); var docId = KnowledgeSearchText.StableId("doc", "finding", findingId); var embedding = _vectorEncoder.Encode(body); @@ -116,15 +125,71 @@ internal sealed class FindingIngestionAdapter : ISearchIngestionAdapter })); } - private string ResolvePath(string configuredPath) + private static string BuildBody( + string findingId, + string cveId, + string title, + string description, + string severity, + string service, + string? reachability, + string? environment, + string? product, + string? policyBadge, + IReadOnlyList tags) { - if (Path.IsPathRooted(configuredPath)) + var bodyParts = new List { - return configuredPath; + $"Finding: {title}", + $"Finding ID: {findingId}", + $"CVE: {cveId}", + "Finding type: vulnerability finding", + $"Severity: {severity}", + $"Reachable: {NormalizeTextOrDefault(reachability, "unknown")}", + "Status: open unresolved" + }; + + if (!string.IsNullOrWhiteSpace(product)) + { + bodyParts.Add($"Product: {product}"); } - var root = string.IsNullOrWhiteSpace(_options.RepositoryRoot) ? "." : _options.RepositoryRoot; - return Path.GetFullPath(Path.Combine(root, configuredPath)); + if (!string.IsNullOrWhiteSpace(environment)) + { + bodyParts.Add($"Environment: {environment}"); + } + + if (!string.IsNullOrWhiteSpace(policyBadge)) + { + bodyParts.Add($"Policy gate: {policyBadge}"); + } + + if (!string.IsNullOrWhiteSpace(service)) + { + bodyParts.Add($"Service: {service}"); + } + + if (tags.Count > 0) + { + bodyParts.Add($"Tags: {string.Join(", ", tags)}"); + } + + if (!string.IsNullOrWhiteSpace(description)) + { + bodyParts.Add(description); + } + + return string.Join("\n", bodyParts); + } + + private static string NormalizeTextOrDefault(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private string ResolvePath(string configuredPath) + { + return KnowledgeSearchRepositoryRootResolver.ResolvePath(_options, configuredPath); } private static string? ReadString(JsonElement obj, string propertyName) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingsSearchAdapter.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingsSearchAdapter.cs index 3829b2001..0105c334e 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingsSearchAdapter.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/FindingsSearchAdapter.cs @@ -181,23 +181,19 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter ? $"{cveId} [{severity}]" : $"{cveId} - {component} [{severity}]"; - var bodyParts = new List { title }; - if (!string.IsNullOrWhiteSpace(description)) - { - bodyParts.Add(description); - } - if (!string.IsNullOrWhiteSpace(reachability)) - { - bodyParts.Add($"Reachability: {reachability}"); - } - if (!string.IsNullOrWhiteSpace(environment)) - { - bodyParts.Add($"Environment: {environment}"); - } - - bodyParts.Add($"Severity: {severity}"); - - var body = string.Join("\n", bodyParts); + var body = BuildBody( + findingId, + cveId, + title, + description, + severity, + "scanner", + reachability, + environment, + product, + policyBadge, + tags, + status: ReadString(entry, "status") ?? ReadString(entry, "Status")); // Scope ids by tenant to prevent cross-tenant overwrite collisions // when different tenants have identical finding ids/cve pairs. var chunkId = KnowledgeSearchText.StableId("chunk", "finding", tenantIdentity, findingId, cveId); @@ -279,9 +275,19 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter var tenantIdentity = NormalizeTenantForIdentity(tenant); var tags = ReadStringArray(entry, "tags", ["finding", "vulnerability", severity]); - var body = string.IsNullOrWhiteSpace(description) - ? $"{title}\nSeverity: {severity}" - : $"{title}\n{description}\nSeverity: {severity}"; + var body = BuildBody( + findingId, + cveId, + title, + description, + severity, + service, + reachability: null, + environment: null, + product: service, + policyBadge: null, + tags, + status: null); // Scope ids by tenant to prevent cross-tenant overwrite collisions // when different tenants have identical finding ids/cve pairs. var chunkId = KnowledgeSearchText.StableId("chunk", "finding", tenantIdentity, findingId, cveId); @@ -331,15 +337,85 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter })); } - private string ResolvePath(string configuredPath) + private static string BuildBody( + string findingId, + string cveId, + string title, + string description, + string severity, + string service, + string? reachability, + string? environment, + string? product, + string? policyBadge, + IReadOnlyList tags, + string? status) { - if (Path.IsPathRooted(configuredPath)) + var bodyParts = new List { - return configuredPath; + $"Finding: {title}", + $"Finding ID: {findingId}", + $"CVE: {cveId}", + "Finding type: vulnerability finding", + $"Severity: {severity}", + $"Reachable: {NormalizeTextOrDefault(reachability, "unknown")}", + $"Status: {ResolveFindingStatus(status)}" + }; + + if (!string.IsNullOrWhiteSpace(product)) + { + bodyParts.Add($"Product: {product}"); } - var root = string.IsNullOrWhiteSpace(_options.RepositoryRoot) ? "." : _options.RepositoryRoot; - return Path.GetFullPath(Path.Combine(root, configuredPath)); + if (!string.IsNullOrWhiteSpace(environment)) + { + bodyParts.Add($"Environment: {environment}"); + } + + if (!string.IsNullOrWhiteSpace(policyBadge)) + { + bodyParts.Add($"Policy gate: {policyBadge}"); + } + + if (!string.IsNullOrWhiteSpace(service)) + { + bodyParts.Add($"Service: {service}"); + } + + if (tags.Count > 0) + { + bodyParts.Add($"Tags: {string.Join(", ", tags)}"); + } + + if (!string.IsNullOrWhiteSpace(description)) + { + bodyParts.Add(description); + } + + return string.Join("\n", bodyParts); + } + + private static string ResolveFindingStatus(string? status) + { + if (string.IsNullOrWhiteSpace(status)) + { + return "open unresolved"; + } + + var normalized = status.Trim().Replace('_', ' '); + return normalized.Contains("open", StringComparison.OrdinalIgnoreCase) + ? normalized + : $"{normalized} unresolved"; + } + + private static string NormalizeTextOrDefault(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private string ResolvePath(string configuredPath) + { + return KnowledgeSearchRepositoryRootResolver.ResolvePath(_options, configuredPath); } private static string? ReadString(JsonElement obj, string propertyName) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/PolicyRuleIngestionAdapter.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/PolicyRuleIngestionAdapter.cs index b6885553b..00c66a031 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/PolicyRuleIngestionAdapter.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/PolicyRuleIngestionAdapter.cs @@ -67,9 +67,15 @@ internal sealed class PolicyRuleIngestionAdapter : ISearchIngestionAdapter var tenant = ReadString(entry, "tenant") ?? "global"; var tags = ReadStringArray(entry, "tags", ["policy", "rule"]); - var body = string.IsNullOrWhiteSpace(decision) - ? $"{title}\nRule: {ruleId}\n{description}" - : $"{title}\nRule: {ruleId}\nDecision: {decision}\n{description}"; + var body = BuildBody( + ruleId, + title, + description, + decision, + service, + scope: null, + environment: null, + tags); var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", ruleId); var docId = KnowledgeSearchText.StableId("doc", "policy_rule", ruleId); var embedding = _vectorEncoder.Encode(body); @@ -113,15 +119,85 @@ internal sealed class PolicyRuleIngestionAdapter : ISearchIngestionAdapter })); } - private string ResolvePath(string configuredPath) + private static string BuildBody( + string ruleId, + string title, + string description, + string? decision, + string service, + string? scope, + string? environment, + IReadOnlyList tags) { - if (Path.IsPathRooted(configuredPath)) + var normalizedDecision = NormalizeTextOrDefault(decision, "unknown"); + var bodyParts = new List { - return configuredPath; + $"Policy rule: {title}", + $"Rule ID: {ruleId}", + $"Gate decision: {normalizedDecision}", + $"Gate state: {ResolveGateState(normalizedDecision)}", + "Policy domain: policy rule gate" + }; + + if (!string.IsNullOrWhiteSpace(scope)) + { + bodyParts.Add($"Scope: {scope}"); } - var root = string.IsNullOrWhiteSpace(_options.RepositoryRoot) ? "." : _options.RepositoryRoot; - return Path.GetFullPath(Path.Combine(root, configuredPath)); + if (!string.IsNullOrWhiteSpace(environment)) + { + bodyParts.Add($"Environment: {environment}"); + } + + if (!string.IsNullOrWhiteSpace(service)) + { + bodyParts.Add($"Service: {service}"); + } + + if (tags.Count > 0) + { + bodyParts.Add($"Tags: {string.Join(", ", tags)}"); + } + + if (!string.IsNullOrWhiteSpace(description)) + { + bodyParts.Add(description); + } + + return string.Join("\n", bodyParts); + } + + private static string ResolveGateState(string decision) + { + if (decision.Contains("deny", StringComparison.OrdinalIgnoreCase) || + decision.Contains("block", StringComparison.OrdinalIgnoreCase)) + { + return "failing blocking"; + } + + if (decision.Contains("warn", StringComparison.OrdinalIgnoreCase)) + { + return "warning advisory"; + } + + if (decision.Contains("pass", StringComparison.OrdinalIgnoreCase) || + decision.Contains("allow", StringComparison.OrdinalIgnoreCase) || + decision.Contains("require", StringComparison.OrdinalIgnoreCase)) + { + return "passing"; + } + + return "unknown"; + } + + private static string NormalizeTextOrDefault(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private string ResolvePath(string configuredPath) + { + return KnowledgeSearchRepositoryRootResolver.ResolvePath(_options, configuredPath); } private static string? ReadString(JsonElement obj, string propertyName) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/PolicySearchAdapter.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/PolicySearchAdapter.cs index 747d52407..22219ffeb 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/PolicySearchAdapter.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/PolicySearchAdapter.cs @@ -191,21 +191,17 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter ? $"{ruleId} [{enforcement}]" : $"{ruleId} - {bomRef} [{enforcement}]"; - var bodyParts = new List { title, $"Rule: {ruleId}", $"Enforcement: {enforcement}" }; - if (!string.IsNullOrWhiteSpace(description)) - { - bodyParts.Add(description); - } - if (!string.IsNullOrWhiteSpace(bomRef)) - { - bodyParts.Add($"Scope: {bomRef}"); - } - if (!string.IsNullOrWhiteSpace(verdictHash)) - { - bodyParts.Add($"Verdict: {verdictHash}"); - } - - var body = string.Join("\n", bodyParts); + var body = BuildBody( + ruleId, + title, + description, + enforcement, + scope, + environment, + verdictHash, + ciContext, + actor, + tags); // Scope ids by tenant to prevent cross-tenant overwrite collisions // when rule ids are reused in different tenants. var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", tenantIdentity, ruleId); @@ -289,9 +285,17 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter var tenantIdentity = NormalizeTenantForIdentity(tenant); var tags = ReadStringArray(entry, "tags", ["policy", "rule"]); - var body = string.IsNullOrWhiteSpace(decision) - ? $"{title}\nRule: {ruleId}\n{description}" - : $"{title}\nRule: {ruleId}\nDecision: {decision}\n{description}"; + var body = BuildBody( + ruleId, + title, + description, + decision, + scope: string.Empty, + environment: string.Empty, + verdictHash: string.Empty, + ciContext: string.Empty, + actor: string.Empty, + tags); // Scope ids by tenant to prevent cross-tenant overwrite collisions // when rule ids are reused in different tenants. var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", tenantIdentity, ruleId); @@ -339,15 +343,100 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter })); } - private string ResolvePath(string configuredPath) + private static string BuildBody( + string ruleId, + string title, + string description, + string? decision, + string? scope, + string? environment, + string? verdictHash, + string? ciContext, + string? actor, + IReadOnlyList tags) { - if (Path.IsPathRooted(configuredPath)) + var normalizedDecision = NormalizeTextOrDefault(decision, "unknown"); + var bodyParts = new List { - return configuredPath; + $"Policy rule: {title}", + $"Rule ID: {ruleId}", + $"Gate decision: {normalizedDecision}", + $"Gate state: {ResolveGateState(normalizedDecision)}", + "Policy domain: policy rule gate" + }; + + if (!string.IsNullOrWhiteSpace(scope)) + { + bodyParts.Add($"Scope: {scope}"); } - var root = string.IsNullOrWhiteSpace(_options.RepositoryRoot) ? "." : _options.RepositoryRoot; - return Path.GetFullPath(Path.Combine(root, configuredPath)); + if (!string.IsNullOrWhiteSpace(environment)) + { + bodyParts.Add($"Environment: {environment}"); + } + + if (!string.IsNullOrWhiteSpace(verdictHash)) + { + bodyParts.Add($"Verdict: {verdictHash}"); + } + + if (!string.IsNullOrWhiteSpace(ciContext)) + { + bodyParts.Add($"CI context: {ciContext}"); + } + + if (!string.IsNullOrWhiteSpace(actor)) + { + bodyParts.Add($"Actor: {actor}"); + } + + if (tags.Count > 0) + { + bodyParts.Add($"Tags: {string.Join(", ", tags)}"); + } + + if (!string.IsNullOrWhiteSpace(description)) + { + bodyParts.Add(description); + } + + return string.Join("\n", bodyParts); + } + + private static string ResolveGateState(string decision) + { + if (decision.Contains("deny", StringComparison.OrdinalIgnoreCase) || + decision.Contains("block", StringComparison.OrdinalIgnoreCase) || + decision.Contains("mandatory", StringComparison.OrdinalIgnoreCase)) + { + return "failing blocking"; + } + + if (decision.Contains("warn", StringComparison.OrdinalIgnoreCase) || + decision.Contains("advisory", StringComparison.OrdinalIgnoreCase)) + { + return "warning advisory"; + } + + if (decision.Contains("pass", StringComparison.OrdinalIgnoreCase) || + decision.Contains("allow", StringComparison.OrdinalIgnoreCase) || + decision.Contains("informational", StringComparison.OrdinalIgnoreCase) || + decision.Contains("require", StringComparison.OrdinalIgnoreCase)) + { + return "passing"; + } + + return "unknown"; + } + + private static string NormalizeTextOrDefault(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private string ResolvePath(string configuredPath) + { + return KnowledgeSearchRepositoryRootResolver.ResolvePath(_options, configuredPath); } private static string? ReadString(JsonElement obj, string propertyName) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/VexSearchAdapter.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/VexSearchAdapter.cs index 505bc6025..ab53d83b0 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/VexSearchAdapter.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/VexSearchAdapter.cs @@ -187,21 +187,16 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter ? $"VEX: {cveId} ({status})" : $"VEX: {cveId} - {product} ({status})"; - var bodyParts = new List { title, $"Status: {status}" }; - if (!string.IsNullOrWhiteSpace(justification)) - { - bodyParts.Add($"Justification: {justification}"); - } - if (!string.IsNullOrWhiteSpace(advisoryTitle)) - { - bodyParts.Add($"Advisory: {advisoryTitle}"); - } - if (!string.IsNullOrWhiteSpace(severity)) - { - bodyParts.Add($"Severity: {severity}"); - } - - var body = string.Join("\n", bodyParts); + var body = BuildBody( + statementId, + cveId, + title, + status, + justification, + product, + advisoryTitle, + severity, + tags); // Scope ids by tenant to prevent cross-tenant overwrite collisions // when statement ids are reused in different tenants. var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", tenantIdentity, statementId); @@ -283,9 +278,16 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter var tags = ReadStringArray(entry, "tags", ["vex", "statement", status]); var title = $"VEX: {cveId} ({status})"; - var body = string.IsNullOrWhiteSpace(justification) - ? $"{title}\nStatus: {status}" - : $"{title}\nStatus: {status}\nJustification: {justification}"; + var body = BuildBody( + statementId, + cveId, + title, + status, + justification, + product: null, + advisoryTitle: null, + severity: null, + tags); // Scope ids by tenant to prevent cross-tenant overwrite collisions // when statement ids are reused in different tenants. var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", tenantIdentity, statementId); @@ -333,15 +335,59 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter })); } - private string ResolvePath(string configuredPath) + private static string BuildBody( + string statementId, + string cveId, + string title, + string status, + string justification, + string? product, + string? advisoryTitle, + string? severity, + IReadOnlyList tags) { - if (Path.IsPathRooted(configuredPath)) + var statusLabel = status.Replace('_', ' '); + var bodyParts = new List { - return configuredPath; + $"VEX statement: {title}", + $"Statement ID: {statementId}", + $"CVE: {cveId}", + $"Disposition: {statusLabel}", + $"Marked as: {statusLabel}", + "Conflict evidence: none recorded" + }; + + if (!string.IsNullOrWhiteSpace(product)) + { + bodyParts.Add($"Covered component: {product}"); } - var root = string.IsNullOrWhiteSpace(_options.RepositoryRoot) ? "." : _options.RepositoryRoot; - return Path.GetFullPath(Path.Combine(root, configuredPath)); + if (!string.IsNullOrWhiteSpace(advisoryTitle)) + { + bodyParts.Add($"Advisory: {advisoryTitle}"); + } + + if (!string.IsNullOrWhiteSpace(severity)) + { + bodyParts.Add($"Severity: {severity}"); + } + + if (tags.Count > 0) + { + bodyParts.Add($"Tags: {string.Join(", ", tags.Select(static tag => tag.Replace('_', ' ')))}"); + } + + if (!string.IsNullOrWhiteSpace(justification)) + { + bodyParts.Add($"Justification: {justification}"); + } + + return string.Join("\n", bodyParts); + } + + private string ResolvePath(string configuredPath) + { + return KnowledgeSearchRepositoryRootResolver.ResolvePath(_options, configuredPath); } private static string? ReadString(JsonElement obj, string propertyName) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/VexStatementIngestionAdapter.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/VexStatementIngestionAdapter.cs index 1ef49e550..e17576a35 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/VexStatementIngestionAdapter.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Adapters/VexStatementIngestionAdapter.cs @@ -68,9 +68,14 @@ internal sealed class VexStatementIngestionAdapter : ISearchIngestionAdapter var tags = ReadStringArray(entry, "tags", ["vex", "statement", status]); var title = $"VEX: {cveId} ({status})"; - var body = string.IsNullOrWhiteSpace(justification) - ? $"{title}\nStatus: {status}" - : $"{title}\nStatus: {status}\nJustification: {justification}"; + var body = BuildBody( + statementId, + cveId, + title, + status, + justification, + product: null, + tags); var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", statementId); var docId = KnowledgeSearchText.StableId("doc", "vex_statement", cveId); var embedding = _vectorEncoder.Encode(body); @@ -116,15 +121,47 @@ internal sealed class VexStatementIngestionAdapter : ISearchIngestionAdapter })); } - private string ResolvePath(string configuredPath) + private static string BuildBody( + string statementId, + string cveId, + string title, + string status, + string justification, + string? product, + IReadOnlyList tags) { - if (Path.IsPathRooted(configuredPath)) + var statusLabel = status.Replace('_', ' '); + var bodyParts = new List { - return configuredPath; + $"VEX statement: {title}", + $"Statement ID: {statementId}", + $"CVE: {cveId}", + $"Disposition: {statusLabel}", + $"Marked as: {statusLabel}", + "Conflict evidence: none recorded" + }; + + if (!string.IsNullOrWhiteSpace(product)) + { + bodyParts.Add($"Covered component: {product}"); } - var root = string.IsNullOrWhiteSpace(_options.RepositoryRoot) ? "." : _options.RepositoryRoot; - return Path.GetFullPath(Path.Combine(root, configuredPath)); + if (tags.Count > 0) + { + bodyParts.Add($"Tags: {string.Join(", ", tags.Select(static tag => tag.Replace('_', ' ')))}"); + } + + if (!string.IsNullOrWhiteSpace(justification)) + { + bodyParts.Add($"Justification: {justification}"); + } + + return string.Join("\n", bodyParts); + } + + private string ResolvePath(string configuredPath) + { + return KnowledgeSearchRepositoryRootResolver.ResolvePath(_options, configuredPath); } private static string? ReadString(JsonElement obj, string propertyName) diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchAnalyticsService.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchAnalyticsService.cs index e9f0be120..427f46e37 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchAnalyticsService.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Analytics/SearchAnalyticsService.cs @@ -349,13 +349,20 @@ internal sealed class SearchAnalyticsService await conn.OpenAsync(ct).ConfigureAwait(false); await using var cmd = new NpgsqlCommand(@" - SELECT DISTINCT query - FROM advisoryai.search_history - WHERE tenant_id = @tenant_id - AND result_count > 0 - AND lower(query) <> lower(@query) - AND similarity(query, @query) > 0.2 - ORDER BY similarity(query, @query) DESC + SELECT candidate_query + FROM ( + SELECT + lower(query) AS normalized_query, + MIN(query) AS candidate_query, + MAX(similarity(query, @query)) AS similarity_score + FROM advisoryai.search_history + WHERE tenant_id = @tenant_id + AND result_count > 0 + AND lower(query) <> lower(@query) + AND similarity(query, @query) > 0.2 + GROUP BY lower(query) + ) AS ranked + ORDER BY similarity_score DESC, candidate_query ASC LIMIT @limit", conn); cmd.CommandTimeout = 5; diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Context/AmbientContextProcessor.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Context/AmbientContextProcessor.cs index f8895af7f..d219e0f95 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Context/AmbientContextProcessor.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Context/AmbientContextProcessor.cs @@ -2,8 +2,8 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch.Context; internal sealed class AmbientContextProcessor { - private const double CurrentRouteBoost = 0.35d; - private const double LastActionDomainBoost = 0.15d; + private const double CurrentRouteBoost = 0.85d; + private const double LastActionDomainBoost = 0.20d; private const double VisibleEntityBoost = 0.20d; private const double LastActionEntityBoost = 0.25d; private static readonly (string Prefix, string Domain)[] RouteDomainMappings = diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs index d200faa41..f5b69e055 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchIndexer.cs @@ -5,6 +5,7 @@ using NpgsqlTypes; using StellaOps.AdvisoryAI.KnowledgeSearch; using System.Text.Json; using System.Diagnostics; +using System.Globalization; using System.Linq; namespace StellaOps.AdvisoryAI.UnifiedSearch; @@ -254,6 +255,7 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer { await using var dataSource = new NpgsqlDataSourceBuilder(_options.ConnectionString).Build(); await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var hasEmbeddingVectorColumn = await HasEmbeddingVectorColumnAsync(connection, cancellationToken).ConfigureAwait(false); // Ensure parent documents exist for each unique DocId var uniqueDocIds = chunks.Select(static c => c.DocId).Distinct(StringComparer.Ordinal).ToArray(); @@ -263,57 +265,163 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer await EnsureDocumentExistsAsync(connection, docId, chunk, cancellationToken).ConfigureAwait(false); } - const string sql = """ - INSERT INTO advisoryai.kb_chunk - ( - chunk_id, doc_id, kind, anchor, section_path, - span_start, span_end, title, body, body_tsv, - embedding, metadata, domain, entity_key, entity_type, freshness, - indexed_at - ) - VALUES - ( - @chunk_id, @doc_id, @kind, @anchor, @section_path, - @span_start, @span_end, @title, @body, - setweight(to_tsvector('simple', coalesce(@title, '')), 'A') || - setweight(to_tsvector('simple', coalesce(@section_path, '')), 'B') || - setweight(to_tsvector('simple', coalesce(@body, '')), 'D'), - @embedding, @metadata::jsonb, @domain, @entity_key, @entity_type, @freshness, - NOW() - ) - ON CONFLICT (chunk_id) DO UPDATE SET - doc_id = EXCLUDED.doc_id, - kind = EXCLUDED.kind, - anchor = EXCLUDED.anchor, - section_path = EXCLUDED.section_path, - span_start = EXCLUDED.span_start, - span_end = EXCLUDED.span_end, - title = EXCLUDED.title, - body = EXCLUDED.body, - body_tsv = EXCLUDED.body_tsv, - embedding = EXCLUDED.embedding, - metadata = EXCLUDED.metadata, - domain = EXCLUDED.domain, - entity_key = EXCLUDED.entity_key, - entity_type = EXCLUDED.entity_type, - freshness = EXCLUDED.freshness, - indexed_at = NOW() - WHERE advisoryai.kb_chunk.doc_id IS DISTINCT FROM EXCLUDED.doc_id - OR advisoryai.kb_chunk.kind IS DISTINCT FROM EXCLUDED.kind - OR advisoryai.kb_chunk.anchor IS DISTINCT FROM EXCLUDED.anchor - OR advisoryai.kb_chunk.section_path IS DISTINCT FROM EXCLUDED.section_path - OR advisoryai.kb_chunk.span_start IS DISTINCT FROM EXCLUDED.span_start - OR advisoryai.kb_chunk.span_end IS DISTINCT FROM EXCLUDED.span_end - OR advisoryai.kb_chunk.title IS DISTINCT FROM EXCLUDED.title - OR advisoryai.kb_chunk.body IS DISTINCT FROM EXCLUDED.body - OR advisoryai.kb_chunk.body_tsv IS DISTINCT FROM EXCLUDED.body_tsv - OR advisoryai.kb_chunk.embedding IS DISTINCT FROM EXCLUDED.embedding - OR advisoryai.kb_chunk.metadata IS DISTINCT FROM EXCLUDED.metadata - OR advisoryai.kb_chunk.domain IS DISTINCT FROM EXCLUDED.domain - OR advisoryai.kb_chunk.entity_key IS DISTINCT FROM EXCLUDED.entity_key - OR advisoryai.kb_chunk.entity_type IS DISTINCT FROM EXCLUDED.entity_type - OR advisoryai.kb_chunk.freshness IS DISTINCT FROM EXCLUDED.freshness; - """; + var sql = hasEmbeddingVectorColumn + ? """ + INSERT INTO advisoryai.kb_chunk + ( + chunk_id, doc_id, kind, anchor, section_path, + span_start, span_end, title, body, body_tsv, + body_tsv_en, body_tsv_de, body_tsv_fr, body_tsv_es, body_tsv_ru, + embedding, embedding_vec, metadata, domain, entity_key, entity_type, freshness, + indexed_at + ) + VALUES + ( + @chunk_id, @doc_id, @kind, @anchor, @section_path, + @span_start, @span_end, @title, @body, + setweight(to_tsvector('simple', coalesce(@title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('simple', coalesce(@body, '')), 'D'), + setweight(to_tsvector('english', coalesce(@title, '')), 'A') || + setweight(to_tsvector('english', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('english', coalesce(@body, '')), 'D'), + setweight(to_tsvector('german', coalesce(@title, '')), 'A') || + setweight(to_tsvector('german', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('german', coalesce(@body, '')), 'D'), + setweight(to_tsvector('french', coalesce(@title, '')), 'A') || + setweight(to_tsvector('french', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('french', coalesce(@body, '')), 'D'), + setweight(to_tsvector('spanish', coalesce(@title, '')), 'A') || + setweight(to_tsvector('spanish', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('spanish', coalesce(@body, '')), 'D'), + setweight(to_tsvector('russian', coalesce(@title, '')), 'A') || + setweight(to_tsvector('russian', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('russian', coalesce(@body, '')), 'D'), + @embedding, CAST(@embedding_vector AS vector), @metadata::jsonb, @domain, @entity_key, @entity_type, @freshness, + NOW() + ) + ON CONFLICT (chunk_id) DO UPDATE SET + doc_id = EXCLUDED.doc_id, + kind = EXCLUDED.kind, + anchor = EXCLUDED.anchor, + section_path = EXCLUDED.section_path, + span_start = EXCLUDED.span_start, + span_end = EXCLUDED.span_end, + title = EXCLUDED.title, + body = EXCLUDED.body, + body_tsv = EXCLUDED.body_tsv, + body_tsv_en = EXCLUDED.body_tsv_en, + body_tsv_de = EXCLUDED.body_tsv_de, + body_tsv_fr = EXCLUDED.body_tsv_fr, + body_tsv_es = EXCLUDED.body_tsv_es, + body_tsv_ru = EXCLUDED.body_tsv_ru, + embedding = EXCLUDED.embedding, + embedding_vec = EXCLUDED.embedding_vec, + metadata = EXCLUDED.metadata, + domain = EXCLUDED.domain, + entity_key = EXCLUDED.entity_key, + entity_type = EXCLUDED.entity_type, + freshness = EXCLUDED.freshness, + indexed_at = NOW() + WHERE advisoryai.kb_chunk.doc_id IS DISTINCT FROM EXCLUDED.doc_id + OR advisoryai.kb_chunk.kind IS DISTINCT FROM EXCLUDED.kind + OR advisoryai.kb_chunk.anchor IS DISTINCT FROM EXCLUDED.anchor + OR advisoryai.kb_chunk.section_path IS DISTINCT FROM EXCLUDED.section_path + OR advisoryai.kb_chunk.span_start IS DISTINCT FROM EXCLUDED.span_start + OR advisoryai.kb_chunk.span_end IS DISTINCT FROM EXCLUDED.span_end + OR advisoryai.kb_chunk.title IS DISTINCT FROM EXCLUDED.title + OR advisoryai.kb_chunk.body IS DISTINCT FROM EXCLUDED.body + OR advisoryai.kb_chunk.body_tsv IS DISTINCT FROM EXCLUDED.body_tsv + OR advisoryai.kb_chunk.body_tsv_en IS DISTINCT FROM EXCLUDED.body_tsv_en + OR advisoryai.kb_chunk.body_tsv_de IS DISTINCT FROM EXCLUDED.body_tsv_de + OR advisoryai.kb_chunk.body_tsv_fr IS DISTINCT FROM EXCLUDED.body_tsv_fr + OR advisoryai.kb_chunk.body_tsv_es IS DISTINCT FROM EXCLUDED.body_tsv_es + OR advisoryai.kb_chunk.body_tsv_ru IS DISTINCT FROM EXCLUDED.body_tsv_ru + OR advisoryai.kb_chunk.embedding IS DISTINCT FROM EXCLUDED.embedding + OR advisoryai.kb_chunk.embedding_vec IS DISTINCT FROM EXCLUDED.embedding_vec + OR advisoryai.kb_chunk.metadata IS DISTINCT FROM EXCLUDED.metadata + OR advisoryai.kb_chunk.domain IS DISTINCT FROM EXCLUDED.domain + OR advisoryai.kb_chunk.entity_key IS DISTINCT FROM EXCLUDED.entity_key + OR advisoryai.kb_chunk.entity_type IS DISTINCT FROM EXCLUDED.entity_type + OR advisoryai.kb_chunk.freshness IS DISTINCT FROM EXCLUDED.freshness; + """ + : """ + INSERT INTO advisoryai.kb_chunk + ( + chunk_id, doc_id, kind, anchor, section_path, + span_start, span_end, title, body, body_tsv, + body_tsv_en, body_tsv_de, body_tsv_fr, body_tsv_es, body_tsv_ru, + embedding, metadata, domain, entity_key, entity_type, freshness, + indexed_at + ) + VALUES + ( + @chunk_id, @doc_id, @kind, @anchor, @section_path, + @span_start, @span_end, @title, @body, + setweight(to_tsvector('simple', coalesce(@title, '')), 'A') || + setweight(to_tsvector('simple', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('simple', coalesce(@body, '')), 'D'), + setweight(to_tsvector('english', coalesce(@title, '')), 'A') || + setweight(to_tsvector('english', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('english', coalesce(@body, '')), 'D'), + setweight(to_tsvector('german', coalesce(@title, '')), 'A') || + setweight(to_tsvector('german', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('german', coalesce(@body, '')), 'D'), + setweight(to_tsvector('french', coalesce(@title, '')), 'A') || + setweight(to_tsvector('french', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('french', coalesce(@body, '')), 'D'), + setweight(to_tsvector('spanish', coalesce(@title, '')), 'A') || + setweight(to_tsvector('spanish', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('spanish', coalesce(@body, '')), 'D'), + setweight(to_tsvector('russian', coalesce(@title, '')), 'A') || + setweight(to_tsvector('russian', coalesce(@section_path, '')), 'B') || + setweight(to_tsvector('russian', coalesce(@body, '')), 'D'), + @embedding, @metadata::jsonb, @domain, @entity_key, @entity_type, @freshness, + NOW() + ) + ON CONFLICT (chunk_id) DO UPDATE SET + doc_id = EXCLUDED.doc_id, + kind = EXCLUDED.kind, + anchor = EXCLUDED.anchor, + section_path = EXCLUDED.section_path, + span_start = EXCLUDED.span_start, + span_end = EXCLUDED.span_end, + title = EXCLUDED.title, + body = EXCLUDED.body, + body_tsv = EXCLUDED.body_tsv, + body_tsv_en = EXCLUDED.body_tsv_en, + body_tsv_de = EXCLUDED.body_tsv_de, + body_tsv_fr = EXCLUDED.body_tsv_fr, + body_tsv_es = EXCLUDED.body_tsv_es, + body_tsv_ru = EXCLUDED.body_tsv_ru, + embedding = EXCLUDED.embedding, + metadata = EXCLUDED.metadata, + domain = EXCLUDED.domain, + entity_key = EXCLUDED.entity_key, + entity_type = EXCLUDED.entity_type, + freshness = EXCLUDED.freshness, + indexed_at = NOW() + WHERE advisoryai.kb_chunk.doc_id IS DISTINCT FROM EXCLUDED.doc_id + OR advisoryai.kb_chunk.kind IS DISTINCT FROM EXCLUDED.kind + OR advisoryai.kb_chunk.anchor IS DISTINCT FROM EXCLUDED.anchor + OR advisoryai.kb_chunk.section_path IS DISTINCT FROM EXCLUDED.section_path + OR advisoryai.kb_chunk.span_start IS DISTINCT FROM EXCLUDED.span_start + OR advisoryai.kb_chunk.span_end IS DISTINCT FROM EXCLUDED.span_end + OR advisoryai.kb_chunk.title IS DISTINCT FROM EXCLUDED.title + OR advisoryai.kb_chunk.body IS DISTINCT FROM EXCLUDED.body + OR advisoryai.kb_chunk.body_tsv IS DISTINCT FROM EXCLUDED.body_tsv + OR advisoryai.kb_chunk.body_tsv_en IS DISTINCT FROM EXCLUDED.body_tsv_en + OR advisoryai.kb_chunk.body_tsv_de IS DISTINCT FROM EXCLUDED.body_tsv_de + OR advisoryai.kb_chunk.body_tsv_fr IS DISTINCT FROM EXCLUDED.body_tsv_fr + OR advisoryai.kb_chunk.body_tsv_es IS DISTINCT FROM EXCLUDED.body_tsv_es + OR advisoryai.kb_chunk.body_tsv_ru IS DISTINCT FROM EXCLUDED.body_tsv_ru + OR advisoryai.kb_chunk.embedding IS DISTINCT FROM EXCLUDED.embedding + OR advisoryai.kb_chunk.metadata IS DISTINCT FROM EXCLUDED.metadata + OR advisoryai.kb_chunk.domain IS DISTINCT FROM EXCLUDED.domain + OR advisoryai.kb_chunk.entity_key IS DISTINCT FROM EXCLUDED.entity_key + OR advisoryai.kb_chunk.entity_type IS DISTINCT FROM EXCLUDED.entity_type + OR advisoryai.kb_chunk.freshness IS DISTINCT FROM EXCLUDED.freshness; + """; await using var command = connection.CreateCommand(); command.CommandText = sql; @@ -336,6 +444,11 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer "embedding", NpgsqlDbType.Array | NpgsqlDbType.Real, chunk.Embedding is null ? Array.Empty() : chunk.Embedding); + if (hasEmbeddingVectorColumn) + { + var vectorLiteral = chunk.Embedding is null ? (object)DBNull.Value : BuildVectorLiteral(chunk.Embedding); + command.Parameters.AddWithValue("embedding_vector", vectorLiteral); + } command.Parameters.AddWithValue("metadata", NpgsqlDbType.Jsonb, chunk.Metadata.RootElement.GetRawText()); command.Parameters.AddWithValue("domain", chunk.Domain); command.Parameters.AddWithValue("entity_key", (object?)chunk.EntityKey ?? DBNull.Value); @@ -349,6 +462,32 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer return affectedRows; } + private static async Task HasEmbeddingVectorColumnAsync( + NpgsqlConnection connection, + CancellationToken cancellationToken) + { + const string sql = """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'advisoryai' + AND table_name = 'kb_chunk' + AND column_name = 'embedding_vec' + ); + """; + + await using var command = connection.CreateCommand(); + command.CommandText = sql; + command.CommandTimeout = 30; + var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return result is bool value && value; + } + + private static string BuildVectorLiteral(float[] values) + { + return "[" + string.Join(",", values.Select(static value => value.ToString("G9", CultureInfo.InvariantCulture))) + "]"; + } + private static async Task EnsureDocumentExistsAsync( NpgsqlConnection connection, string docId, diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchService.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchService.cs index c13905a95..b8670c8af 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchService.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchService.cs @@ -179,7 +179,11 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService aggregateCoverage = MergeCoverage(aggregateCoverage, response.Coverage); var cardCount = response.Cards.Count + (response.Overflow?.Cards.Count ?? 0); var answer = response.ContextAnswer; - var viabilityState = DetermineSuggestionViabilityState(cardCount, answer, corpusAvailability); + var viabilityState = DetermineSuggestionViabilityState( + cardCount, + answer, + response.Coverage, + corpusAvailability); results.Add(new SearchSuggestionViabilityResult( Query: query, @@ -191,7 +195,11 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService ?? response.Overflow?.Cards.FirstOrDefault()?.Domain ?? response.Coverage?.Domains.FirstOrDefault(static domain => domain.HasVisibleResults)?.Domain ?? response.Coverage?.CurrentScopeDomain, - Reason: BuildSuggestionViabilityReason(answer, viabilityState, corpusAvailability), + Reason: BuildSuggestionViabilityReason( + answer, + viabilityState, + response.Coverage, + corpusAvailability), ViabilityState: viabilityState, ScopeReady: IsCurrentScopeReady(corpusAvailability))); } @@ -834,7 +842,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService UnifiedSearchOverflow? overflow) { var topCard = answerCards[0]; - var scope = coverage?.CurrentScopeDomain + var currentScopeDomain = coverage?.CurrentScopeDomain; + var groundedScope = ResolveGroundedScopeDomain(coverage, topCard) ?? ResolveContextDomain(plan, [topCard], ambient) ?? topCard.Domain; @@ -843,12 +852,19 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService return $"The highest-ranked results are close in score, so the answer blends evidence across {FormatDomainList(answerCards.Select(static card => card.Domain))}."; } - if (overflow is not null) + if (overflow is not null && !string.IsNullOrWhiteSpace(currentScopeDomain)) { - return $"Current-scope weighting kept {DescribeDomain(scope)} first, while close related evidence from other domains remains visible below."; + return $"Current-scope weighting kept {DescribeDomain(currentScopeDomain)} first, while close related evidence from other domains remains visible below."; } - return $"The top result is grounded in {DescribeDomain(scope)} evidence and aligns with the {plan.Intent} intent."; + if (!string.IsNullOrWhiteSpace(currentScopeDomain) + && !string.Equals(groundedScope, currentScopeDomain, StringComparison.OrdinalIgnoreCase) + && !HasVisibleCurrentScopeResults(coverage)) + { + return $"The best grounded evidence came from {DescribeDomain(groundedScope)} after current-page weighting found no stronger match in {DescribeDomain(currentScopeDomain)}."; + } + + return $"The top result is grounded in {DescribeDomain(groundedScope)} evidence and aligns with the {plan.Intent} intent."; } private static string BuildGroundedEvidence( @@ -871,6 +887,13 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService return $"Grounded in {sourceCount} source(s) across {FormatDomainList(domains)} with {DescribeDomain(currentScopeDomain)} weighted first."; } + if (coverage?.CurrentScopeDomain is { Length: > 0 } visibleScopeDomain + && !HasVisibleCurrentScopeResults(coverage) + && !string.Equals(answerCards[0].Domain, visibleScopeDomain, StringComparison.OrdinalIgnoreCase)) + { + return $"Grounded in {sourceCount} source(s) across {FormatDomainList(domains)}. No visible evidence ranked in {DescribeDomain(visibleScopeDomain)}, so the answer falls back to {DescribeDomain(answerCards[0].Domain)}."; + } + return $"Grounded in {sourceCount} source(s) across {FormatDomainList(domains)}."; } @@ -1350,10 +1373,26 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService private static string DetermineSuggestionViabilityState( int cardCount, ContextAnswer? answer, + UnifiedSearchCoverage? coverage, CorpusAvailabilitySnapshot corpusAvailability) { + if (string.Equals(ResolveCorpusUnreadyCode(corpusAvailability), "current_scope_corpus_unready", StringComparison.Ordinal) + && cardCount > 0 + && !HasVisibleCurrentScopeResults(coverage)) + { + return "scope_unready"; + } + if (cardCount > 0) { + if (!string.IsNullOrWhiteSpace(coverage?.CurrentScopeDomain) + && !string.Equals(ResolveCorpusUnreadyCode(corpusAvailability), "current_scope_corpus_unready", StringComparison.Ordinal) + && !HasVisibleCurrentScopeResults(coverage) + && CardCountExistsOutsideCurrentScope(answer, coverage)) + { + return "outside_scope_only"; + } + return "grounded"; } @@ -1389,18 +1428,86 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService private static string BuildSuggestionViabilityReason( ContextAnswer? answer, string viabilityState, + UnifiedSearchCoverage? coverage, CorpusAvailabilitySnapshot corpusAvailability) { return viabilityState switch { "grounded" => answer?.Reason ?? "Grounded evidence is available for this suggestion.", + "outside_scope_only" => BuildOutsideScopeOnlyReason(coverage), "needs_clarification" => answer?.Reason ?? "The query is too broad to surface as a ready-made suggestion.", "scope_unready" => BuildCorpusUnreadyReason(plan: null, ambient: null, corpusAvailability), "corpus_unready" => BuildCorpusUnreadyReason(plan: null, ambient: null, corpusAvailability), + "no_match" when CardCountExistsOutsideCurrentScope(answer, coverage) => + BuildOutsideScopeOnlyReason(coverage), _ => answer?.Reason ?? "No grounded evidence matched this suggestion in the current corpus." }; } + private static bool HasVisibleCurrentScopeResults(UnifiedSearchCoverage? coverage) + { + if (coverage is null || string.IsNullOrWhiteSpace(coverage.CurrentScopeDomain)) + { + return false; + } + + return coverage.Domains.Any(domain => + domain.IsCurrentScope + && domain.HasVisibleResults + && domain.VisibleCardCount > 0); + } + + private static string? ResolveGroundedScopeDomain( + UnifiedSearchCoverage? coverage, + EntityCard topCard) + { + if (coverage is null) + { + return null; + } + + if (HasVisibleCurrentScopeResults(coverage) + && !string.IsNullOrWhiteSpace(coverage.CurrentScopeDomain)) + { + return coverage.CurrentScopeDomain; + } + + if (!string.IsNullOrWhiteSpace(topCard.Domain)) + { + return topCard.Domain; + } + + return coverage.CurrentScopeDomain; + } + + private static bool CardCountExistsOutsideCurrentScope( + ContextAnswer? answer, + UnifiedSearchCoverage? coverage) + { + if (!string.Equals(answer?.Status, "grounded", StringComparison.OrdinalIgnoreCase) + || coverage is null + || string.IsNullOrWhiteSpace(coverage.CurrentScopeDomain)) + { + return false; + } + + return !HasVisibleCurrentScopeResults(coverage) + && coverage.Domains.Any(domain => domain.HasVisibleResults && !domain.IsCurrentScope); + } + + private static string BuildOutsideScopeOnlyReason(UnifiedSearchCoverage? coverage) + { + var currentScopeDomain = coverage?.CurrentScopeDomain; + var visibleDomain = coverage?.Domains.FirstOrDefault(static domain => domain.HasVisibleResults)?.Domain; + + if (!string.IsNullOrWhiteSpace(currentScopeDomain) && !string.IsNullOrWhiteSpace(visibleDomain)) + { + return $"The current page did not return grounded evidence in {DescribeDomain(currentScopeDomain)}, but related evidence is available in {DescribeDomain(visibleDomain)}."; + } + + return "The current page did not return grounded evidence, but related evidence is available outside the current scope."; + } + private static string? ResolveCorpusUnreadyCode(CorpusAvailabilitySnapshot corpusAvailability) { if (!corpusAvailability.Known) diff --git a/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md b/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md index d1f07deee..19bacbe1b 100644 --- a/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md +++ b/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md @@ -149,9 +149,12 @@ Migrations run automatically when the service starts (`EnsureSchemaAsync()`). Or ```bash # Configure connection string for the local AdvisoryAI WebService export AdvisoryAI__KnowledgeSearch__ConnectionString="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge" -export AdvisoryAI__KnowledgeSearch__RepositoryRoot="$(pwd)" ``` +Notes: +- AdvisoryAI knowledge ingestion now auto-detects the repository root by walking up from the current working directory and `AppContext.BaseDirectory` until it finds a Stella Ops checkout shape (`global.json`, `src/`, `docs/`, `devops/`). +- `AdvisoryAI__KnowledgeSearch__RepositoryRoot` is still supported, but only for non-standard layouts where the service binary is not running from inside a normal repository checkout. + #### CLI availability in a source checkout Do not assume `stella` already exists on `PATH` in a local repo checkout. @@ -186,12 +189,16 @@ For live search and Playwright suggestion tests, rebuild both indexes in this or # 1. Knowledge corpus: docs + OpenAPI + Doctor checks export STELLAOPS_BACKEND_URL="http://127.0.0.1:10451" dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai sources prepare --json -dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai index rebuild --json +curl -X POST http://127.0.0.1:10451/v1/advisory-ai/index/rebuild \ + -H "X-StellaOps-Scopes: advisory-ai:admin" \ + -H "X-StellaOps-Tenant: test-tenant" \ + -H "X-StellaOps-Actor: local-search-test" # 2. Unified domain overlays: platform, graph, scanner, timeline, opsmemory curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \ -H "X-StellaOps-Scopes: advisory-ai:admin" \ - -H "X-StellaOps-Tenant: test-tenant" + -H "X-StellaOps-Tenant: test-tenant" \ + -H "X-StellaOps-Actor: local-search-test" ``` Or use HTTP only when the CLI is not built yet: @@ -200,16 +207,19 @@ Or use HTTP only when the CLI is not built yet: # 1. Knowledge corpus rebuild curl -X POST http://127.0.0.1:10451/v1/advisory-ai/index/rebuild \ -H "X-StellaOps-Scopes: advisory-ai:admin" \ - -H "X-StellaOps-Tenant: test-tenant" + -H "X-StellaOps-Tenant: test-tenant" \ + -H "X-StellaOps-Actor: local-search-test" # 2. Unified overlay rebuild curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \ -H "X-StellaOps-Scopes: advisory-ai:admin" \ - -H "X-StellaOps-Tenant: test-tenant" + -H "X-StellaOps-Tenant: test-tenant" \ + -H "X-StellaOps-Actor: local-search-test" ``` Notes: - `stella advisoryai sources prepare` needs `STELLAOPS_BACKEND_URL` or equivalent CLI config when it performs live Doctor discovery. If you only need local search verification and the checked-in Doctor seed/control files are sufficient, the HTTP-only rebuild path is valid. +- `stella advisoryai index rebuild` and `stella search index rebuild` call authenticated backend endpoints. In the local live-search test lane above, use direct HTTP requests with explicit `X-StellaOps-*` headers unless you already have an authenticated CLI session. - Current live verification coverage includes the Doctor/knowledge query `database connectivity`, which returns `contextAnswer.status = grounded` plus citations after the rebuild sequence above. Migration files (all idempotent, safe to re-run): diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchLiveAdapterIntegrationTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchLiveAdapterIntegrationTests.cs index b236edee3..d8d440d15 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchLiveAdapterIntegrationTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchLiveAdapterIntegrationTests.cs @@ -59,6 +59,9 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests chunks[0].EntityType.Should().Be("finding"); chunks[0].EntityKey.Should().Be("cve:CVE-2026-0001"); chunks[0].Title.Should().Contain("CVE-2026-0001"); + chunks[0].Body.Should().Contain("Finding type: vulnerability finding"); + chunks[0].Body.Should().Contain("Reachable: reachable"); + chunks[0].Body.Should().Contain("Status: open unresolved"); handler.Requests.Should().ContainSingle(); handler.Requests[0].Tenant.Should().Be("global"); @@ -101,6 +104,8 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests chunks[0].EntityKey.Should().Be("cve:CVE-2026-4242"); chunks[0].Domain.Should().Be("findings"); chunks[0].Title.Should().Contain("Snapshot finding"); + chunks[0].Body.Should().Contain("Finding type: vulnerability finding"); + chunks[0].Body.Should().Contain("Status: open unresolved"); } finally { @@ -144,6 +149,9 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests chunks[0].EntityType.Should().Be("vex_statement"); chunks[0].EntityKey.Should().Be("cve:CVE-2026-1111"); chunks[0].Title.Should().Contain("CVE-2026-1111"); + chunks[0].Body.Should().Contain("Marked as: not affected"); + chunks[0].Body.Should().Contain("Covered component: pkg:nuget/Contoso.Widget"); + chunks[0].Body.Should().Contain("Conflict evidence: none recorded"); handler.Requests.Should().ContainSingle(); handler.Requests[0].Tenant.Should().Be("global"); @@ -186,6 +194,8 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests chunks[0].EntityType.Should().Be("vex_statement"); chunks[0].EntityKey.Should().Be("cve:CVE-2026-5151"); chunks[0].Title.Should().Contain("CVE-2026-5151"); + chunks[0].Body.Should().Contain("Marked as: not affected"); + chunks[0].Body.Should().Contain("Conflict evidence: none recorded"); } finally { @@ -227,6 +237,8 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests chunks[0].EntityType.Should().Be("policy_rule"); chunks[0].EntityKey.Should().Be("rule:DENY-CRITICAL-PROD"); chunks[0].Title.Should().Contain("DENY-CRITICAL-PROD"); + chunks[0].Body.Should().Contain("Policy domain: policy rule gate"); + chunks[0].Body.Should().Contain("Gate state: failing blocking"); handler.Requests.Should().ContainSingle(); handler.Requests[0].Tenant.Should().Be("global"); @@ -269,6 +281,8 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests chunks[0].EntityType.Should().Be("policy_rule"); chunks[0].EntityKey.Should().Be("rule:ALLOW-STAGING-SMOKE"); chunks[0].Title.Should().Contain("Allow staging smoke tests"); + chunks[0].Body.Should().Contain("Policy domain: policy rule gate"); + chunks[0].Body.Should().Contain("Gate decision: warn"); } finally { @@ -358,6 +372,85 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests (await CountDomainChunksAsync(connection, "policy")).Should().Be(4); } + [Fact] + public async Task UnifiedSearchIndexer_RebuildAllAsync_PopulatesEnglishFtsColumns_AndRecallsUnifiedDomains() + { + await using var fixture = await StartPostgresOrSkipAsync(); + var options = Options.Create(new KnowledgeSearchOptions + { + Enabled = true, + ConnectionString = fixture.ConnectionString, + FtsLanguageConfig = "english", + FindingsAdapterEnabled = true, + FindingsAdapterBaseUrl = "http://scanner.local", + VexAdapterEnabled = true, + VexAdapterBaseUrl = "http://concelier.local", + PolicyAdapterEnabled = true, + PolicyAdapterBaseUrl = "http://policy.local" + }); + + await using var store = new PostgresKnowledgeSearchStore(options, NullLogger.Instance); + await EnsureKnowledgeSchemaAsync(fixture.ConnectionString); + + var findingsHandler = new RecordingHttpMessageHandler(_ => JsonResponse(BuildFindingsPayload(3))); + var vexHandler = new RecordingHttpMessageHandler(_ => JsonResponse(BuildVexPayload(3))); + var policyHandler = new RecordingHttpMessageHandler(_ => JsonResponse(BuildPolicyPayload(3))); + + var indexer = new UnifiedSearchIndexer( + options, + store, + [ + new FindingsSearchAdapter( + new SingleClientFactory(findingsHandler, "http://scanner.local"), + CreateVectorEncoder(), + options, + NullLogger.Instance), + new VexSearchAdapter( + new SingleClientFactory(vexHandler, "http://concelier.local"), + CreateVectorEncoder(), + options, + NullLogger.Instance), + new PolicySearchAdapter( + new SingleClientFactory(policyHandler, "http://policy.local"), + CreateVectorEncoder(), + options, + NullLogger.Instance) + ], + NullLogger.Instance); + + await indexer.RebuildAllAsync(CancellationToken.None); + + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + (await CountEnglishTsvRowsAsync(connection, "findings")).Should().Be(3); + (await CountEnglishTsvRowsAsync(connection, "policy")).Should().Be(3); + (await CountEnglishTsvRowsAsync(connection, "vex")).Should().Be(3); + + var findingsRows = await store.SearchFtsAsync( + "critical findings", + new KnowledgeSearchFilter { Tenant = "global" }, + 10, + TimeSpan.FromSeconds(2), + CancellationToken.None); + findingsRows.Should().Contain(row => row.Kind == "finding" && row.Body.Contains("Finding type: vulnerability finding")); + + var policyRows = await store.SearchFtsAsync( + "failing policy gates", + new KnowledgeSearchFilter { Tenant = "global" }, + 10, + TimeSpan.FromSeconds(2), + CancellationToken.None); + policyRows.Should().Contain(row => row.Kind == "policy_rule" && row.Body.Contains("Policy domain: policy rule gate")); + + var vexRows = await store.SearchFtsAsync( + "marked not affected", + new KnowledgeSearchFilter { Tenant = "global" }, + 10, + TimeSpan.FromSeconds(2), + CancellationToken.None); + vexRows.Should().Contain(row => row.Kind == "vex_statement" && row.Body.Contains("Marked as: not affected")); + } + [Fact] public async Task UnifiedSearchIndexer_RebuildAllAsync_EnsuresSchema_WhenTablesAreMissing() { @@ -888,6 +981,20 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests return Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture); } + private static async Task CountEnglishTsvRowsAsync(NpgsqlConnection connection, string domain) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) + FROM advisoryai.kb_chunk + WHERE domain = @domain + AND body_tsv_en IS NOT NULL; + """; + command.Parameters.AddWithValue("domain", domain); + var scalar = await command.ExecuteScalarAsync(); + return Convert.ToInt32(scalar, CultureInfo.InvariantCulture); + } + private static async Task ReadIndexedAtAsync(NpgsqlConnection connection, string chunkId) { await using var command = connection.CreateCommand(); diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchSprintIntegrationTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchSprintIntegrationTests.cs index d593562f5..93bae7dfc 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchSprintIntegrationTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/UnifiedSearchSprintIntegrationTests.cs @@ -1744,6 +1744,33 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable stored.Comment.Should().BeNull("free-form comments are redacted to avoid storing potential PII"); } + [Fact] + public async Task G10_SimilarSuccessfulQueries_LoadFromDatabaseWithoutFallingBack() + { + var tenant = $"similar-queries-{Guid.NewGuid():N}"; + const string userId = "similar-user"; + + using (var seedScope = _factory.Services.CreateScope()) + { + var analytics = seedScope.ServiceProvider.GetRequiredService(); + await analytics.RecordHistoryAsync(tenant, userId, "database connectivity", 2); + await analytics.RecordHistoryAsync(tenant, userId, "database health", 1); + await analytics.RecordHistoryAsync(tenant, userId, "database readiness", 1); + } + + using var queryScope = _factory.Services.CreateScope(); + var queryAnalytics = queryScope.ServiceProvider.GetRequiredService(); + + var similar = await queryAnalytics.FindSimilarSuccessfulQueriesAsync( + tenant, + "database connectivty", + 3, + CancellationToken.None); + + similar.Should().Contain("database connectivity"); + similar.Should().Contain(query => query.StartsWith("database ", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task G10_AnalyticsCollection_Overhead_IsBelowFiveMillisecondsPerEvent() { diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/KnowledgeSearchRepositoryRootResolverTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/KnowledgeSearchRepositoryRootResolverTests.cs new file mode 100644 index 000000000..519d02d96 --- /dev/null +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/KnowledgeSearchRepositoryRootResolverTests.cs @@ -0,0 +1,102 @@ +using StellaOps.AdvisoryAI.KnowledgeSearch; +using Xunit; + +namespace StellaOps.AdvisoryAI.Tests.KnowledgeSearch; + +[Trait("Category", "Unit")] +public sealed class KnowledgeSearchRepositoryRootResolverTests +{ + [Fact] + public void Resolve_detects_repository_root_from_service_directory_without_explicit_override() + { + using var fixture = RepositoryRootFixture.Create(); + var options = new KnowledgeSearchOptions(); + + var resolution = KnowledgeSearchRepositoryRootResolver.Resolve( + options, + fixture.ServiceDirectory, + fixture.AppBaseDirectory); + + Assert.True(resolution.Validated); + Assert.Equal(fixture.RepositoryRoot, resolution.Path); + } + + [Fact] + public void Resolve_falls_back_from_invalid_configured_root_to_detected_repository_root() + { + using var fixture = RepositoryRootFixture.Create(); + var options = new KnowledgeSearchOptions + { + RepositoryRoot = Path.Combine(fixture.WorkspaceRoot, "invalid-root"), + }; + + var resolution = KnowledgeSearchRepositoryRootResolver.Resolve( + options, + fixture.ServiceDirectory, + fixture.AppBaseDirectory); + + Assert.True(resolution.Validated); + Assert.Equal(fixture.RepositoryRoot, resolution.Path); + Assert.NotEqual("configured", resolution.Source); + } + + private sealed class RepositoryRootFixture : IDisposable + { + private RepositoryRootFixture( + string workspaceRoot, + string repositoryRoot, + string serviceDirectory, + string appBaseDirectory) + { + WorkspaceRoot = workspaceRoot; + RepositoryRoot = repositoryRoot; + ServiceDirectory = serviceDirectory; + AppBaseDirectory = appBaseDirectory; + } + + public string WorkspaceRoot { get; } + + public string RepositoryRoot { get; } + + public string ServiceDirectory { get; } + + public string AppBaseDirectory { get; } + + public static RepositoryRootFixture Create() + { + var workspaceRoot = Path.Combine(Path.GetTempPath(), "stellaops-knowledge-root-" + Guid.NewGuid().ToString("N")); + var repositoryRoot = Path.Combine(workspaceRoot, "repo"); + var serviceDirectory = Path.Combine(repositoryRoot, "src", "AdvisoryAI", "StellaOps.AdvisoryAI.WebService"); + var appBaseDirectory = Path.Combine(serviceDirectory, "bin", "Debug", "net10.0"); + + Directory.CreateDirectory(repositoryRoot); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "docs")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "devops", "compose")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "src", "AdvisoryAI", "StellaOps.AdvisoryAI", "KnowledgeSearch")); + Directory.CreateDirectory(appBaseDirectory); + + File.WriteAllText(Path.Combine(repositoryRoot, "global.json"), "{}"); + File.WriteAllText(Path.Combine(repositoryRoot, "docs", "README.md"), "# docs"); + File.WriteAllText(Path.Combine(repositoryRoot, "devops", "compose", "openapi_current.json"), "{}"); + File.WriteAllText( + Path.Combine(repositoryRoot, "src", "AdvisoryAI", "StellaOps.AdvisoryAI", "KnowledgeSearch", "knowledge-docs-allowlist.json"), + "[]"); + File.WriteAllText( + Path.Combine(repositoryRoot, "src", "AdvisoryAI", "StellaOps.AdvisoryAI", "KnowledgeSearch", "doctor-search-seed.json"), + "[]"); + File.WriteAllText( + Path.Combine(repositoryRoot, "src", "AdvisoryAI", "StellaOps.AdvisoryAI", "KnowledgeSearch", "doctor-search-controls.json"), + "[]"); + + return new RepositoryRootFixture(workspaceRoot, repositoryRoot, serviceDirectory, appBaseDirectory); + } + + public void Dispose() + { + if (Directory.Exists(WorkspaceRoot)) + { + Directory.Delete(WorkspaceRoot, recursive: true); + } + } + } +} diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchIngestionAdaptersTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchIngestionAdaptersTests.cs index 307d31529..5689ce17a 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchIngestionAdaptersTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchIngestionAdaptersTests.cs @@ -5,6 +5,7 @@ using StellaOps.AdvisoryAI.KnowledgeSearch; using StellaOps.AdvisoryAI.UnifiedSearch; using StellaOps.AdvisoryAI.UnifiedSearch.Adapters; using StellaOps.AdvisoryAI.Vectorization; +using System.Net.Http; using System.Text.Json; using Xunit; @@ -289,6 +290,50 @@ public sealed class UnifiedSearchIngestionAdaptersTests } } + [Fact] + public async Task Default_snapshot_paths_resolve_from_repository_root_when_running_from_service_directory() + { + using var fixture = RepositoryRootFixture.Create(); + var originalDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(fixture.ServiceDirectory); + + var findingsAdapter = new FindingIngestionAdapter( + new StubVectorEncoder(), + Options.Create(new KnowledgeSearchOptions()), + NullLogger.Instance); + var policyAdapter = new PolicySearchAdapter( + new StubHttpClientFactory(), + new StubVectorEncoder(), + Options.Create(new KnowledgeSearchOptions()), + NullLogger.Instance); + var vexAdapter = new VexSearchAdapter( + new StubHttpClientFactory(), + new StubVectorEncoder(), + Options.Create(new KnowledgeSearchOptions()), + NullLogger.Instance); + + var findings = await findingsAdapter.ProduceChunksAsync(CancellationToken.None); + var policies = await policyAdapter.ProduceChunksAsync(CancellationToken.None); + var vexStatements = await vexAdapter.ProduceChunksAsync(CancellationToken.None); + + findings.Should().ContainSingle(); + findings[0].EntityKey.Should().Be("cve:CVE-2026-1111"); + + policies.Should().ContainSingle(); + policies[0].EntityKey.Should().Be("rule:release.production.gate"); + + vexStatements.Should().ContainSingle(); + vexStatements[0].EntityKey.Should().Be("cve:CVE-2026-1111"); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + } + } + private static string CreateTempDirectory() { var path = Path.Combine(Path.GetTempPath(), "stellaops-adapter-tests-" + Guid.NewGuid().ToString("N")); @@ -308,4 +353,93 @@ public sealed class UnifiedSearchIngestionAdaptersTests { public float[] Encode(string text) => [0.12f, 0.34f, 0.56f, 0.78f]; } + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } + + private sealed class RepositoryRootFixture : IDisposable + { + private RepositoryRootFixture(string workspaceRoot, string repositoryRoot, string serviceDirectory) + { + WorkspaceRoot = workspaceRoot; + RepositoryRoot = repositoryRoot; + ServiceDirectory = serviceDirectory; + } + + public string WorkspaceRoot { get; } + + public string RepositoryRoot { get; } + + public string ServiceDirectory { get; } + + public static RepositoryRootFixture Create() + { + var workspaceRoot = Path.Combine(Path.GetTempPath(), "stellaops-unified-snapshots-" + Guid.NewGuid().ToString("N")); + var repositoryRoot = Path.Combine(workspaceRoot, "repo"); + var snapshotDirectory = Path.Combine(repositoryRoot, "src", "AdvisoryAI", "StellaOps.AdvisoryAI", "UnifiedSearch", "Snapshots"); + var serviceDirectory = Path.Combine(repositoryRoot, "src", "AdvisoryAI", "StellaOps.AdvisoryAI.WebService"); + + Directory.CreateDirectory(Path.Combine(repositoryRoot, "docs")); + Directory.CreateDirectory(Path.Combine(repositoryRoot, "devops")); + Directory.CreateDirectory(snapshotDirectory); + Directory.CreateDirectory(serviceDirectory); + + File.WriteAllText(Path.Combine(repositoryRoot, "global.json"), "{}"); + File.WriteAllText(Path.Combine(repositoryRoot, "docs", "README.md"), "# docs"); + File.WriteAllText(Path.Combine(snapshotDirectory, "findings.snapshot.json"), JsonSerializer.Serialize(new[] + { + new + { + cveId = "CVE-2026-1111", + findingId = "finding-1", + severity = "high", + title = "Critical finding", + description = "reachable production issue", + service = "scanner", + tenant = "tenant-a", + tags = new[] { "finding", "reachable" }, + } + })); + File.WriteAllText(Path.Combine(snapshotDirectory, "policy.snapshot.json"), JsonSerializer.Serialize(new[] + { + new + { + ruleId = "release.production.gate", + title = "Production release gate", + description = "Blocks production on unresolved reachability.", + decision = "deny", + service = "policy", + tenant = "tenant-a", + tags = new[] { "policy", "release" }, + freshness = "2026-03-07T00:00:00Z", + } + })); + File.WriteAllText(Path.Combine(snapshotDirectory, "vex.snapshot.json"), JsonSerializer.Serialize(new[] + { + new + { + cveId = "CVE-2026-1111", + status = "affected", + statementId = "stmt-1", + justification = "reachable in production", + service = "vex-hub", + tenant = "tenant-a", + tags = new[] { "vex", "affected" }, + freshness = "2026-03-07T00:00:00Z", + } + })); + + return new RepositoryRootFixture(workspaceRoot, repositoryRoot, serviceDirectory); + } + + public void Dispose() + { + if (Directory.Exists(WorkspaceRoot)) + { + Directory.Delete(WorkspaceRoot, recursive: true); + } + } + } } diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchServiceTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchServiceTests.cs index 145ee979b..4b9cb81cc 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchServiceTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchServiceTests.cs @@ -190,6 +190,51 @@ public sealed class UnifiedSearchServiceTests result.ContextAnswer.Citations.Should().ContainSingle(); } + [Fact] + public async Task SearchAsync_describes_the_actual_winning_domain_when_current_scope_has_no_visible_results() + { + var knowledgeRow = MakeRow( + "chunk-doctor-fallback-answer", + "doctor_check", + "PostgreSQL connectivity", + JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"), + snippet: "Doctor knowledge confirms the database endpoint is down."); + + var service = CreateService( + store: new CorpusAvailabilityTestStore( + ftsRows: new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["database connectivity"] = [knowledgeRow] + }, + domainAvailability: + [ + new KnowledgeSearchDomainCorpusAvailability("knowledge", 8), + new KnowledgeSearchDomainCorpusAvailability("findings", 0) + ])); + + var result = await service.SearchAsync( + new UnifiedSearchRequest( + "database connectivity", + Ambient: new AmbientContext + { + CurrentRoute = "/security/triage" + }), + CancellationToken.None); + + result.Cards.Should().ContainSingle(); + result.Cards[0].Domain.Should().Be("knowledge"); + result.Coverage.Should().NotBeNull(); + result.Coverage!.CurrentScopeDomain.Should().Be("findings"); + result.Coverage.Domains.Should().Contain(domain => + domain.Domain == "findings" && domain.IsCurrentScope && !domain.HasVisibleResults); + result.ContextAnswer.Should().NotBeNull(); + result.ContextAnswer!.Reason.Should().Contain("knowledge scope"); + result.ContextAnswer.Reason.Should().Contain("findings scope"); + result.ContextAnswer.Reason.Should().NotContain("grounded in the findings scope"); + result.ContextAnswer.Evidence.Should().Contain("No visible evidence ranked in the findings scope"); + result.ContextAnswer.Evidence.Should().Contain("falls back to the knowledge scope"); + } + [Fact] public async Task SearchAsync_blends_close_top_results_into_one_answer() { @@ -645,6 +690,86 @@ public sealed class UnifiedSearchServiceTests result.Suggestions[0].ScopeReady.Should().BeFalse(); } + [Fact] + public async Task EvaluateSuggestionsAsync_rejects_outside_scope_only_hits_when_the_current_route_corpus_is_unready() + { + var knowledgeRow = MakeRow( + "chunk-doctor-viability-fallback", + "doctor_check", + "PostgreSQL connectivity", + JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"), + snippet: "Doctor knowledge confirms the database endpoint is down."); + + var service = CreateService( + store: new CorpusAvailabilityTestStore( + ftsRows: new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["database connectivity"] = [knowledgeRow] + }, + domainAvailability: + [ + new KnowledgeSearchDomainCorpusAvailability("knowledge", 8), + new KnowledgeSearchDomainCorpusAvailability("findings", 0) + ])); + + var result = await service.EvaluateSuggestionsAsync( + new SearchSuggestionViabilityRequest( + Queries: ["database connectivity"], + Ambient: new AmbientContext + { + CurrentRoute = "/security/triage" + }), + CancellationToken.None); + + result.Suggestions.Should().ContainSingle(); + result.Suggestions[0].Viable.Should().BeFalse(); + result.Suggestions[0].Status.Should().Be("grounded"); + result.Suggestions[0].LeadingDomain.Should().Be("knowledge"); + result.Suggestions[0].ViabilityState.Should().Be("scope_unready"); + result.Suggestions[0].Reason.Should().Contain("findings scope"); + result.Suggestions[0].ScopeReady.Should().BeFalse(); + } + + [Fact] + public async Task EvaluateSuggestionsAsync_rejects_outside_scope_only_hits_when_current_scope_is_populated() + { + var knowledgeRow = MakeRow( + "chunk-doctor-viability-outside-scope", + "doctor_check", + "PostgreSQL connectivity", + JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"), + snippet: "Doctor knowledge confirms the database endpoint is down."); + + var service = CreateService( + store: new CorpusAvailabilityTestStore( + ftsRows: new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["critical findings"] = [knowledgeRow] + }, + domainAvailability: + [ + new KnowledgeSearchDomainCorpusAvailability("knowledge", 8), + new KnowledgeSearchDomainCorpusAvailability("findings", 3) + ])); + + var result = await service.EvaluateSuggestionsAsync( + new SearchSuggestionViabilityRequest( + Queries: ["critical findings"], + Ambient: new AmbientContext + { + CurrentRoute = "/security/triage" + }), + CancellationToken.None); + + result.Suggestions.Should().ContainSingle(); + result.Suggestions[0].Viable.Should().BeFalse(); + result.Suggestions[0].Status.Should().Be("grounded"); + result.Suggestions[0].LeadingDomain.Should().Be("knowledge"); + result.Suggestions[0].ViabilityState.Should().Be("outside_scope_only"); + result.Suggestions[0].Reason.Should().Contain("findings scope"); + result.Suggestions[0].ScopeReady.Should().BeTrue(); + } + [Fact] public async Task EvaluateSuggestionsAsync_returns_viability_without_recording_answer_frame_analytics() {