Harden live-backed unified search weighting and indexing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<BenchmarkTarget> LoadMarkdownTargets(string repositoryRoot, CancellationToken cancellationToken)
|
||||
|
||||
@@ -55,13 +55,13 @@ public sealed class KnowledgeSearchOptions
|
||||
public List<string> 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; }
|
||||
|
||||
|
||||
@@ -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<string>(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<string> 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<string> 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<string> BuildRelativeMarkers(KnowledgeSearchOptions options)
|
||||
{
|
||||
var markers = new HashSet<string>(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<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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<string> tags)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
var bodyParts = new List<string>
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -181,23 +181,19 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
|
||||
? $"{cveId} [{severity}]"
|
||||
: $"{cveId} - {component} [{severity}]";
|
||||
|
||||
var bodyParts = new List<string> { 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<string> tags,
|
||||
string? status)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
var bodyParts = new List<string>
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -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<string> tags)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
var normalizedDecision = NormalizeTextOrDefault(decision, "unknown");
|
||||
var bodyParts = new List<string>
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -191,21 +191,17 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
|
||||
? $"{ruleId} [{enforcement}]"
|
||||
: $"{ruleId} - {bomRef} [{enforcement}]";
|
||||
|
||||
var bodyParts = new List<string> { 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<string> tags)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
var normalizedDecision = NormalizeTextOrDefault(decision, "unknown");
|
||||
var bodyParts = new List<string>
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -187,21 +187,16 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
|
||||
? $"VEX: {cveId} ({status})"
|
||||
: $"VEX: {cveId} - {product} ({status})";
|
||||
|
||||
var bodyParts = new List<string> { 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<string> tags)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
var statusLabel = status.Replace('_', ' ');
|
||||
var bodyParts = new List<string>
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -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<string> tags)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
var statusLabel = status.Replace('_', ' ');
|
||||
var bodyParts = new List<string>
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<float>() : 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<bool> 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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<PostgresKnowledgeSearchStore>.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<FindingsSearchAdapter>.Instance),
|
||||
new VexSearchAdapter(
|
||||
new SingleClientFactory(vexHandler, "http://concelier.local"),
|
||||
CreateVectorEncoder(),
|
||||
options,
|
||||
NullLogger<VexSearchAdapter>.Instance),
|
||||
new PolicySearchAdapter(
|
||||
new SingleClientFactory(policyHandler, "http://policy.local"),
|
||||
CreateVectorEncoder(),
|
||||
options,
|
||||
NullLogger<PolicySearchAdapter>.Instance)
|
||||
],
|
||||
NullLogger<UnifiedSearchIndexer>.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<int> 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<DateTimeOffset> ReadIndexedAtAsync(NpgsqlConnection connection, string chunkId)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
|
||||
@@ -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<SearchAnalyticsService>();
|
||||
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<SearchAnalyticsService>();
|
||||
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FindingIngestionAdapter>.Instance);
|
||||
var policyAdapter = new PolicySearchAdapter(
|
||||
new StubHttpClientFactory(),
|
||||
new StubVectorEncoder(),
|
||||
Options.Create(new KnowledgeSearchOptions()),
|
||||
NullLogger<PolicySearchAdapter>.Instance);
|
||||
var vexAdapter = new VexSearchAdapter(
|
||||
new StubHttpClientFactory(),
|
||||
new StubVectorEncoder(),
|
||||
Options.Create(new KnowledgeSearchOptions()),
|
||||
NullLogger<VexSearchAdapter>.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, IReadOnlyList<KnowledgeChunkRow>>(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<string, IReadOnlyList<KnowledgeChunkRow>>(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<string, IReadOnlyList<KnowledgeChunkRow>>(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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user