Add implicit scope weighting and suggestion viability

This commit is contained in:
master
2026-03-07 18:21:43 +02:00
parent a2218d70fa
commit 86a4928109
14 changed files with 1070 additions and 68 deletions

View File

@@ -63,6 +63,17 @@ public static class UnifiedSearchEndpoints
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/suggestions/evaluate", EvaluateSuggestionsAsync)
.WithName("UnifiedSearchEvaluateSuggestions")
.WithSummary("Preflights contextual search suggestions against the active corpus.")
.WithDescription(
"Evaluates a bounded list of suggested queries without recording user-search analytics so the UI can suppress dead suggestion chips. " +
"Returns per-query viability plus aggregate domain coverage for the active context.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<UnifiedSearchSuggestionViabilityApiResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/synthesize", SynthesizeAsync)
.WithName("UnifiedSearchSynthesize")
.WithSummary("Streams deterministic-first search synthesis as SSE.")
@@ -131,6 +142,44 @@ public static class UnifiedSearchEndpoints
}
}
private static async Task<IResult> EvaluateSuggestionsAsync(
HttpContext httpContext,
UnifiedSearchSuggestionViabilityApiRequest request,
IUnifiedSearchService searchService,
CancellationToken cancellationToken)
{
if (request is null || request.Queries is not { Count: > 0 })
{
return Results.BadRequest(new { error = _t("advisoryai.validation.suggestions_required") });
}
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
try
{
var userScopes = ResolveUserScopes(httpContext);
var userId = ResolveUserId(httpContext);
var domainRequest = new SearchSuggestionViabilityRequest(
request.Queries
.Where(static query => !string.IsNullOrWhiteSpace(query))
.Select(static query => query.Trim())
.ToArray(),
NormalizeFilter(request.Filters, tenant, userScopes, userId),
NormalizeAmbient(request.Ambient));
var response = await searchService.EvaluateSuggestionsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
return Results.Ok(MapSuggestionViabilityResponse(response));
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task SynthesizeAsync(
HttpContext httpContext,
UnifiedSearchSynthesizeApiRequest request,
@@ -460,7 +509,9 @@ public static class UnifiedSearchEndpoints
suggestions = response.Suggestions.Select(static s => new UnifiedSearchApiSuggestion
{
Text = s.Text,
Reason = s.Reason
Reason = s.Reason,
Domain = s.Domain,
CandidateCount = s.CandidateCount
}).ToArray();
}
@@ -470,7 +521,9 @@ public static class UnifiedSearchEndpoints
refinements = response.Refinements.Select(static r => new UnifiedSearchApiRefinement
{
Text = r.Text,
Source = r.Source
Source = r.Source,
Domain = r.Domain,
CandidateCount = r.CandidateCount
}).ToArray();
}
@@ -504,6 +557,59 @@ public static class UnifiedSearchEndpoints
Query = response.Query,
TopK = response.TopK,
Cards = cards,
Overflow = response.Overflow is null
? null
: new UnifiedSearchApiOverflow
{
CurrentScopeDomain = response.Overflow.CurrentScopeDomain,
Reason = response.Overflow.Reason,
Cards = response.Overflow.Cards.Select(static card => new UnifiedSearchApiCard
{
EntityKey = card.EntityKey,
EntityType = card.EntityType,
Domain = card.Domain,
Title = card.Title,
Snippet = card.Snippet,
Score = card.Score,
Severity = card.Severity,
Actions = card.Actions.Select(static action => new UnifiedSearchApiAction
{
Label = action.Label,
ActionType = action.ActionType,
Route = action.Route,
Command = action.Command,
IsPrimary = action.IsPrimary
}).ToArray(),
Metadata = card.Metadata,
Sources = card.Sources.ToArray(),
Facets = card.Facets.Select(static facet => new UnifiedSearchApiFacet
{
Domain = facet.Domain,
Title = facet.Title,
Snippet = facet.Snippet,
Score = facet.Score,
Metadata = facet.Metadata
}).ToArray(),
Connections = card.Connections.ToArray(),
SynthesisHints = card.SynthesisHints
}).ToArray()
},
Coverage = response.Coverage is null
? null
: new UnifiedSearchApiCoverage
{
CurrentScopeDomain = response.Coverage.CurrentScopeDomain,
CurrentScopeWeighted = response.Coverage.CurrentScopeWeighted,
Domains = response.Coverage.Domains.Select(static domain => new UnifiedSearchApiDomainCoverage
{
Domain = domain.Domain,
CandidateCount = domain.CandidateCount,
VisibleCardCount = domain.VisibleCardCount,
TopScore = domain.TopScore,
IsCurrentScope = domain.IsCurrentScope,
HasVisibleResults = domain.HasVisibleResults
}).ToArray()
},
Synthesis = synthesis,
Suggestions = suggestions,
Refinements = refinements,
@@ -528,6 +634,40 @@ public static class UnifiedSearchEndpoints
};
}
private static UnifiedSearchSuggestionViabilityApiResponse MapSuggestionViabilityResponse(
SearchSuggestionViabilityResponse response)
{
return new UnifiedSearchSuggestionViabilityApiResponse
{
Suggestions = response.Suggestions.Select(static suggestion => new UnifiedSearchSuggestionViabilityApiResult
{
Query = suggestion.Query,
Viable = suggestion.Viable,
Status = suggestion.Status,
Code = suggestion.Code,
CardCount = suggestion.CardCount,
LeadingDomain = suggestion.LeadingDomain,
Reason = suggestion.Reason
}).ToArray(),
Coverage = response.Coverage is null
? null
: new UnifiedSearchApiCoverage
{
CurrentScopeDomain = response.Coverage.CurrentScopeDomain,
CurrentScopeWeighted = response.Coverage.CurrentScopeWeighted,
Domains = response.Coverage.Domains.Select(static domain => new UnifiedSearchApiDomainCoverage
{
Domain = domain.Domain,
CandidateCount = domain.CandidateCount,
VisibleCardCount = domain.VisibleCardCount,
TopScore = domain.TopScore,
IsCurrentScope = domain.IsCurrentScope,
HasVisibleResults = domain.HasVisibleResults
}).ToArray()
}
};
}
private static bool HasSynthesisScope(HttpContext context)
{
var scopes = ResolveUserScopes(context);
@@ -723,6 +863,15 @@ public sealed record UnifiedSearchApiRequest
public UnifiedSearchApiAmbientContext? Ambient { get; init; }
}
public sealed record UnifiedSearchSuggestionViabilityApiRequest
{
public IReadOnlyList<string> Queries { get; init; } = [];
public UnifiedSearchApiFilter? Filters { get; init; }
public UnifiedSearchApiAmbientContext? Ambient { get; init; }
}
public sealed record UnifiedSearchApiAmbientContext
{
public string? CurrentRoute { get; init; }
@@ -802,6 +951,10 @@ public sealed record UnifiedSearchApiResponse
public IReadOnlyList<UnifiedSearchApiCard> Cards { get; init; } = [];
public UnifiedSearchApiOverflow? Overflow { get; init; }
public UnifiedSearchApiCoverage? Coverage { get; init; }
public UnifiedSearchApiSynthesis? Synthesis { get; init; }
public IReadOnlyList<UnifiedSearchApiSuggestion>? Suggestions { get; init; }
@@ -815,6 +968,30 @@ public sealed record UnifiedSearchApiResponse
public IReadOnlyList<UnifiedSearchApiFederationDiagnostic>? Federation { get; init; }
}
public sealed record UnifiedSearchSuggestionViabilityApiResponse
{
public IReadOnlyList<UnifiedSearchSuggestionViabilityApiResult> Suggestions { get; init; } = [];
public UnifiedSearchApiCoverage? Coverage { get; init; }
}
public sealed record UnifiedSearchSuggestionViabilityApiResult
{
public string Query { get; init; } = string.Empty;
public bool Viable { get; init; }
public string Status { get; init; } = string.Empty;
public string Code { get; init; } = string.Empty;
public int CardCount { get; init; }
public string? LeadingDomain { get; init; }
public string Reason { get; init; } = string.Empty;
}
public sealed record UnifiedSearchApiCard
{
public string EntityKey { get; init; } = string.Empty;
@@ -845,6 +1022,39 @@ public sealed record UnifiedSearchApiCard
new Dictionary<string, string>(StringComparer.Ordinal);
}
public sealed record UnifiedSearchApiOverflow
{
public string CurrentScopeDomain { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public IReadOnlyList<UnifiedSearchApiCard> Cards { get; init; } = [];
}
public sealed record UnifiedSearchApiCoverage
{
public string? CurrentScopeDomain { get; init; }
public bool CurrentScopeWeighted { get; init; }
public IReadOnlyList<UnifiedSearchApiDomainCoverage> Domains { get; init; } = [];
}
public sealed record UnifiedSearchApiDomainCoverage
{
public string Domain { get; init; } = string.Empty;
public int CandidateCount { get; init; }
public int VisibleCardCount { get; init; }
public double TopScore { get; init; }
public bool IsCurrentScope { get; init; }
public bool HasVisibleResults { get; init; }
}
public sealed record UnifiedSearchApiFacet
{
public string Domain { get; init; } = "knowledge";
@@ -889,6 +1099,10 @@ public sealed record UnifiedSearchApiSuggestion
public string Text { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public string? Domain { get; init; }
public int CandidateCount { get; init; }
}
public sealed record UnifiedSearchApiRefinement
@@ -896,6 +1110,10 @@ public sealed record UnifiedSearchApiRefinement
public string Text { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
public string? Domain { get; init; }
public int CandidateCount { get; init; }
}
public sealed record UnifiedSearchApiContextAnswer

View File

@@ -19,3 +19,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| AI-SELF-001 | DONE | Unified search endpoint contract now exposes backend contextual answer fields for self-serve search. |
| AI-SELF-004 | DONE | Search analytics and quality endpoints now surface self-serve metrics and alerts (`fallback_loop`, `abandoned_fallback`) while keeping telemetry optional. |
| AI-SELF-006 | DONE | Endpoint readiness now includes a proven local rebuilt-corpus verification lane in addition to stubbed integration tests. |
| SPRINT_20260307_019-AI-ZL | DONE | Unified search endpoint contract now exposes additive `overflow`/`coverage` query metadata and `POST /v1/search/suggestions/evaluate` for bounded suggestion viability checks. |

View File

@@ -15,6 +15,7 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
| AI-SELF-003 | DONE | Follow-up question generation from route/domain intent, recent actions, and evidence is implemented. |
| AI-SELF-004 | DONE | Optional self-serve telemetry now captures answer frames, reformulations, rescue actions, and abandonment signals via hashed session ids; quality metrics and alerts expose the gaps operationally. |
| 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_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. |

View File

@@ -2,8 +2,8 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch.Context;
internal sealed class AmbientContextProcessor
{
private const double CurrentRouteBoost = 0.10d;
private const double LastActionDomainBoost = 0.05d;
private const double CurrentRouteBoost = 0.35d;
private const double LastActionDomainBoost = 0.15d;
private const double VisibleEntityBoost = 0.20d;
private const double LastActionEntityBoost = 0.25d;
private static readonly (string Prefix, string Domain)[] RouteDomainMappings =

View File

@@ -3,4 +3,8 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch;
public interface IUnifiedSearchService
{
Task<UnifiedSearchResponse> SearchAsync(UnifiedSearchRequest request, CancellationToken cancellationToken);
Task<SearchSuggestionViabilityResponse> EvaluateSuggestionsAsync(
SearchSuggestionViabilityRequest request,
CancellationToken cancellationToken);
}

View File

@@ -90,9 +90,17 @@ public sealed record AmbientAction
public DateTimeOffset? OccurredAt { get; init; }
}
public sealed record SearchSuggestion(string Text, string Reason);
public sealed record SearchSuggestion(
string Text,
string Reason,
string? Domain = null,
int CandidateCount = 0);
public sealed record SearchRefinement(string Text, string Source);
public sealed record SearchRefinement(
string Text,
string Source,
string? Domain = null,
int CandidateCount = 0);
public sealed record ContextAnswer(
string Status,
@@ -121,7 +129,45 @@ public sealed record UnifiedSearchResponse(
UnifiedSearchDiagnostics Diagnostics,
IReadOnlyList<SearchSuggestion>? Suggestions = null,
IReadOnlyList<SearchRefinement>? Refinements = null,
ContextAnswer? ContextAnswer = null);
ContextAnswer? ContextAnswer = null,
UnifiedSearchOverflow? Overflow = null,
UnifiedSearchCoverage? Coverage = null);
public sealed record UnifiedSearchOverflow(
string CurrentScopeDomain,
string Reason,
IReadOnlyList<EntityCard> Cards);
public sealed record UnifiedSearchCoverage(
string? CurrentScopeDomain,
bool CurrentScopeWeighted,
IReadOnlyList<UnifiedSearchDomainCoverage> Domains);
public sealed record UnifiedSearchDomainCoverage(
string Domain,
int CandidateCount,
int VisibleCardCount,
double TopScore,
bool IsCurrentScope,
bool HasVisibleResults);
public sealed record SearchSuggestionViabilityRequest(
IReadOnlyList<string> Queries,
UnifiedSearchFilter? Filters = null,
AmbientContext? Ambient = null);
public sealed record SearchSuggestionViabilityResponse(
IReadOnlyList<SearchSuggestionViabilityResult> Suggestions,
UnifiedSearchCoverage Coverage);
public sealed record SearchSuggestionViabilityResult(
string Query,
bool Viable,
string Status,
string Code,
int CardCount,
string? LeadingDomain,
string Reason);
public sealed record EntityCard
{

View File

@@ -21,6 +21,11 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
private const int MaxContextAnswerCitations = 3;
private const int MaxContextAnswerQuestions = 3;
private const int ClarifyTokenThreshold = 3;
private const int MaxSuggestionViabilityQueries = 6;
private const int MaxOverflowCards = 4;
private const int CoverageCandidateWindow = 24;
private const double OverflowScoreBandRatio = 0.15d;
private const double BlendedAnswerScoreBandRatio = 0.12d;
private readonly KnowledgeSearchOptions _options;
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly IKnowledgeSearchStore _store;
@@ -57,6 +62,12 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
// Refinement threshold: only suggest when result count is below this (G10-004)
private const int RefinementResultThreshold = 3;
private enum SearchExecutionKind
{
UserVisible,
SuggestionPreflight
}
public UnifiedSearchService(
IOptions<KnowledgeSearchOptions> options,
IKnowledgeSearchStore store,
@@ -96,7 +107,77 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
_telemetrySink = telemetrySink;
}
public async Task<UnifiedSearchResponse> SearchAsync(UnifiedSearchRequest request, CancellationToken cancellationToken)
public Task<UnifiedSearchResponse> SearchAsync(UnifiedSearchRequest request, CancellationToken cancellationToken)
{
return SearchAsyncInternal(request, cancellationToken, SearchExecutionKind.UserVisible);
}
public async Task<SearchSuggestionViabilityResponse> EvaluateSuggestionsAsync(
SearchSuggestionViabilityRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var normalizedQueries = request.Queries
.Where(static query => !string.IsNullOrWhiteSpace(query))
.Select(static query => KnowledgeSearchText.NormalizeWhitespace(query))
.Where(static query => !string.IsNullOrWhiteSpace(query))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MaxSuggestionViabilityQueries)
.ToArray();
var fallbackCoverage = new UnifiedSearchCoverage(
CurrentScopeDomain: ResolveAmbientScopeDomain(request.Ambient),
CurrentScopeWeighted: !string.IsNullOrWhiteSpace(request.Ambient?.CurrentRoute),
Domains: []);
if (normalizedQueries.Length == 0)
{
return new SearchSuggestionViabilityResponse([], fallbackCoverage);
}
var results = new List<SearchSuggestionViabilityResult>(normalizedQueries.Length);
UnifiedSearchCoverage? aggregateCoverage = null;
foreach (var query in normalizedQueries)
{
var response = await SearchAsyncInternal(
new UnifiedSearchRequest(
query,
K: Math.Min(5, _unifiedOptions.MaxCards),
Filters: request.Filters,
IncludeSynthesis: false,
IncludeDebug: false,
Ambient: request.Ambient),
cancellationToken,
SearchExecutionKind.SuggestionPreflight).ConfigureAwait(false);
aggregateCoverage = MergeCoverage(aggregateCoverage, response.Coverage);
var cardCount = response.Cards.Count + (response.Overflow?.Cards.Count ?? 0);
var answer = response.ContextAnswer;
results.Add(new SearchSuggestionViabilityResult(
Query: query,
Viable: cardCount > 0 || string.Equals(answer?.Status, "clarify", StringComparison.OrdinalIgnoreCase),
Status: answer?.Status ?? "insufficient",
Code: answer?.Code ?? "no_grounded_evidence",
CardCount: cardCount,
LeadingDomain: response.Cards.FirstOrDefault()?.Domain
?? response.Overflow?.Cards.FirstOrDefault()?.Domain
?? response.Coverage?.Domains.FirstOrDefault(static domain => domain.HasVisibleResults)?.Domain
?? response.Coverage?.CurrentScopeDomain,
Reason: answer?.Reason ?? "No grounded evidence matched the suggestion in the active corpus."));
}
return new SearchSuggestionViabilityResponse(
results,
aggregateCoverage ?? fallbackCoverage);
}
private async Task<UnifiedSearchResponse> SearchAsyncInternal(
UnifiedSearchRequest request,
CancellationToken cancellationToken,
SearchExecutionKind executionKind)
{
ArgumentNullException.ThrowIfNull(request);
@@ -107,13 +188,17 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return EmptyResponse(string.Empty, request.K, "empty");
}
var emitObservability = executionKind == SearchExecutionKind.UserVisible;
var tenantId = request.Filters?.Tenant ?? "global";
var userId = request.Filters?.UserId ?? "anonymous";
if (query.Length > _unifiedOptions.MaxQueryLength)
{
var earlyResponse = EmptyResponse(query, request.K, "query_too_long", request.Ambient);
await RecordAnswerFrameAnalyticsAsync(tenantId, userId, request, plan: null, earlyResponse, cancellationToken).ConfigureAwait(false);
if (emitObservability)
{
await RecordAnswerFrameAnalyticsAsync(tenantId, userId, request, plan: null, earlyResponse, cancellationToken).ConfigureAwait(false);
}
return earlyResponse;
}
@@ -122,7 +207,10 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
if (!_options.Enabled || !IsSearchEnabledForTenant(tenantFlags) || string.IsNullOrWhiteSpace(_options.ConnectionString))
{
var earlyResponse = EmptyResponse(query, request.K, "disabled", request.Ambient);
await RecordAnswerFrameAnalyticsAsync(tenantId, userId, request, plan: null, earlyResponse, cancellationToken).ConfigureAwait(false);
if (emitObservability)
{
await RecordAnswerFrameAnalyticsAsync(tenantId, userId, request, plan: null, earlyResponse, cancellationToken).ConfigureAwait(false);
}
return earlyResponse;
}
@@ -266,7 +354,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
cards = cards.Take(Math.Max(1, _unifiedOptions.MaxCards)).ToArray();
if (!string.IsNullOrWhiteSpace(request.Ambient?.SessionId))
if (emitObservability && !string.IsNullOrWhiteSpace(request.Ambient?.SessionId))
{
_searchSessionContext.RecordQuery(
tenantId,
@@ -276,16 +364,21 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
_timeProvider.GetUtcNow());
}
var currentScopeDomain = ResolveAmbientScopeDomain(request.Ambient);
var (primaryCards, overflow) = PartitionCardsByScope(cards, currentScopeDomain);
var visibleCards = BuildVisibleAnswerCards(primaryCards, overflow);
var coverage = BuildCoverage(currentScopeDomain, merged, primaryCards, overflow);
SynthesisResult? synthesis = null;
if (request.IncludeSynthesis && IsSynthesisEnabledForTenant(tenantFlags) && cards.Count > 0)
if (request.IncludeSynthesis && IsSynthesisEnabledForTenant(tenantFlags) && visibleCards.Count > 0)
{
synthesis = await _synthesisEngine.SynthesizeAsync(
query, cards, plan.DetectedEntities, cancellationToken).ConfigureAwait(false);
query, visibleCards, plan.DetectedEntities, cancellationToken).ConfigureAwait(false);
}
// G4-003: Generate "Did you mean?" suggestions when results are sparse
IReadOnlyList<SearchSuggestion>? suggestions = null;
if (cards.Count < _options.MinFtsResultsForFuzzyFallback && _options.FuzzyFallbackEnabled)
if (visibleCards.Count < _options.MinFtsResultsForFuzzyFallback && _options.FuzzyFallbackEnabled)
{
suggestions = await GenerateSuggestionsAsync(
query, storeFilter, cancellationToken).ConfigureAwait(false);
@@ -293,10 +386,10 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
// G10-004: Generate query refinement suggestions from feedback data
IReadOnlyList<SearchRefinement>? refinements = null;
if (cards.Count < RefinementResultThreshold)
if (visibleCards.Count < RefinementResultThreshold)
{
refinements = await GenerateRefinementsAsync(
tenantId, query, cards.Count, cancellationToken).ConfigureAwait(false);
tenantId, query, visibleCards.Count, storeFilter, cancellationToken).ConfigureAwait(false);
}
var duration = _timeProvider.GetUtcNow() - startedAt;
@@ -304,19 +397,22 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
query,
plan,
request.Ambient,
cards,
primaryCards,
overflow,
synthesis,
suggestions,
refinements);
refinements,
coverage);
var totalVisibleCardCount = primaryCards.Count + (overflow?.Cards.Count ?? 0);
var response = new UnifiedSearchResponse(
query,
topK,
cards,
primaryCards,
synthesis,
new UnifiedSearchDiagnostics(
ftsRows.Count,
vectorRows.Length,
cards.Count,
totalVisibleCardCount,
(long)duration.TotalMilliseconds,
usedVector,
usedVector ? "hybrid" : "fts-only",
@@ -324,10 +420,16 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
federationDiagnostics),
suggestions,
refinements,
contextAnswer);
contextAnswer,
overflow,
coverage);
if (emitObservability)
{
await RecordAnswerFrameAnalyticsAsync(tenantId, userId, request, plan, response, cancellationToken).ConfigureAwait(false);
EmitTelemetry(plan, response, tenantId);
}
await RecordAnswerFrameAnalyticsAsync(tenantId, userId, request, plan, response, cancellationToken).ConfigureAwait(false);
EmitTelemetry(plan, response, tenantId);
return response;
}
@@ -388,6 +490,198 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
};
}
private static (IReadOnlyList<EntityCard> PrimaryCards, UnifiedSearchOverflow? Overflow) PartitionCardsByScope(
IReadOnlyList<EntityCard> rankedCards,
string? currentScopeDomain)
{
if (rankedCards.Count == 0 || string.IsNullOrWhiteSpace(currentScopeDomain))
{
return (rankedCards, null);
}
var scopedCards = rankedCards
.Where(card => string.Equals(card.Domain, currentScopeDomain, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (scopedCards.Length == 0)
{
return (rankedCards, null);
}
var overflowCards = rankedCards
.Where(card => !string.Equals(card.Domain, currentScopeDomain, StringComparison.OrdinalIgnoreCase))
.Where(card => ShouldSurfaceOverflow(scopedCards[0].Score, card.Score))
.Take(MaxOverflowCards)
.ToArray();
if (overflowCards.Length == 0)
{
return (scopedCards, null);
}
return (
scopedCards,
new UnifiedSearchOverflow(
currentScopeDomain,
BuildOverflowReason(currentScopeDomain, scopedCards[0], overflowCards[0]),
overflowCards));
}
private static bool ShouldSurfaceOverflow(double topScopedScore, double candidateScore)
{
if (candidateScore >= topScopedScore)
{
return true;
}
var denominator = Math.Max(Math.Abs(topScopedScore), 0.000001d);
var relativeGap = (topScopedScore - candidateScore) / denominator;
return relativeGap <= OverflowScoreBandRatio;
}
private static string BuildOverflowReason(string currentScopeDomain, EntityCard topScopedCard, EntityCard topOverflowCard)
{
if (topOverflowCard.Score > topScopedCard.Score)
{
return $"Related results outside {DescribeDomain(currentScopeDomain)} outranked the current-scope evidence.";
}
return $"Related results outside {DescribeDomain(currentScopeDomain)} are close enough in score to surface below the in-scope results.";
}
private static IReadOnlyList<EntityCard> BuildVisibleAnswerCards(
IReadOnlyList<EntityCard> primaryCards,
UnifiedSearchOverflow? overflow)
{
return primaryCards
.Concat(overflow?.Cards ?? [])
.OrderByDescending(static card => card.Score)
.ThenBy(static card => card.Title, StringComparer.Ordinal)
.ThenBy(static card => card.EntityKey, StringComparer.Ordinal)
.ToArray();
}
private static IReadOnlyList<EntityCard> SelectDominantAnswerCards(IReadOnlyList<EntityCard> visibleCards)
{
if (visibleCards.Count <= 1)
{
return visibleCards.Take(1).ToArray();
}
var topScore = Math.Max(Math.Abs(visibleCards[0].Score), 0.000001d);
var blended = visibleCards
.Where(card => (topScore - card.Score) / topScore <= BlendedAnswerScoreBandRatio)
.Take(MaxContextAnswerCitations)
.ToArray();
return blended.Length >= 2
? blended
: visibleCards.Take(1).ToArray();
}
private static UnifiedSearchCoverage BuildCoverage(
string? currentScopeDomain,
IReadOnlyList<(KnowledgeChunkRow Row, double Score, IReadOnlyDictionary<string, string> Debug)> merged,
IReadOnlyList<EntityCard> primaryCards,
UnifiedSearchOverflow? overflow)
{
var visibleCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var card in primaryCards.Concat(overflow?.Cards ?? []))
{
visibleCounts[card.Domain] = visibleCounts.TryGetValue(card.Domain, out var existing)
? existing + 1
: 1;
}
var domains = merged
.Take(CoverageCandidateWindow)
.GroupBy(static item => GetDomain(item.Row), StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var domain = group.Key;
var visibleCount = visibleCounts.TryGetValue(domain, out var count) ? count : 0;
return new UnifiedSearchDomainCoverage(
Domain: domain,
CandidateCount: group.Count(),
VisibleCardCount: visibleCount,
TopScore: group.Max(static item => item.Score),
IsCurrentScope: string.Equals(domain, currentScopeDomain, StringComparison.OrdinalIgnoreCase),
HasVisibleResults: visibleCount > 0);
})
.ToDictionary(static entry => entry.Domain, StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(currentScopeDomain) && !domains.ContainsKey(currentScopeDomain))
{
domains[currentScopeDomain] = new UnifiedSearchDomainCoverage(
Domain: currentScopeDomain,
CandidateCount: 0,
VisibleCardCount: 0,
TopScore: 0d,
IsCurrentScope: true,
HasVisibleResults: false);
}
var ordered = domains.Values
.OrderByDescending(static entry => entry.IsCurrentScope)
.ThenByDescending(static entry => entry.HasVisibleResults)
.ThenByDescending(static entry => entry.CandidateCount)
.ThenByDescending(static entry => entry.TopScore)
.ThenBy(static entry => entry.Domain, StringComparer.Ordinal)
.ToArray();
return new UnifiedSearchCoverage(
CurrentScopeDomain: currentScopeDomain,
CurrentScopeWeighted: !string.IsNullOrWhiteSpace(currentScopeDomain),
Domains: ordered);
}
private static UnifiedSearchCoverage MergeCoverage(
UnifiedSearchCoverage? existing,
UnifiedSearchCoverage? current)
{
if (current is null)
{
return existing ?? new UnifiedSearchCoverage(null, false, []);
}
if (existing is null)
{
return current;
}
var domains = new Dictionary<string, UnifiedSearchDomainCoverage>(StringComparer.OrdinalIgnoreCase);
foreach (var coverage in existing.Domains.Concat(current.Domains))
{
if (!domains.TryGetValue(coverage.Domain, out var prior))
{
domains[coverage.Domain] = coverage;
continue;
}
domains[coverage.Domain] = new UnifiedSearchDomainCoverage(
Domain: coverage.Domain,
CandidateCount: Math.Max(prior.CandidateCount, coverage.CandidateCount),
VisibleCardCount: Math.Max(prior.VisibleCardCount, coverage.VisibleCardCount),
TopScore: Math.Max(prior.TopScore, coverage.TopScore),
IsCurrentScope: prior.IsCurrentScope || coverage.IsCurrentScope,
HasVisibleResults: prior.HasVisibleResults || coverage.HasVisibleResults);
}
var currentScopeDomain = current.CurrentScopeDomain
?? existing.CurrentScopeDomain;
return new UnifiedSearchCoverage(
CurrentScopeDomain: currentScopeDomain,
CurrentScopeWeighted: existing.CurrentScopeWeighted || current.CurrentScopeWeighted,
Domains: domains.Values
.OrderByDescending(static entry => entry.IsCurrentScope)
.ThenByDescending(static entry => entry.HasVisibleResults)
.ThenByDescending(static entry => entry.CandidateCount)
.ThenByDescending(static entry => entry.TopScore)
.ThenBy(static entry => entry.Domain, StringComparer.Ordinal)
.ToArray());
}
private static IReadOnlyList<ContextAnswerCitation> BuildContextAnswerCitations(
IReadOnlyList<EntityCard> cards,
SynthesisResult? synthesis)
@@ -441,7 +735,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return citations;
}
private static string BuildGroundedSummary(IReadOnlyList<EntityCard> cards, SynthesisResult? synthesis)
private static string BuildGroundedSummary(IReadOnlyList<EntityCard> answerCards, SynthesisResult? synthesis)
{
var synthesisSummary = synthesis?.Summary?.Trim();
if (!string.IsNullOrWhiteSpace(synthesisSummary))
@@ -449,27 +743,71 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return synthesisSummary;
}
var topCard = cards[0];
var topCard = answerCards[0];
if (answerCards.Count > 1)
{
var related = answerCards
.Skip(1)
.Select(static card => card.Title)
.Where(static title => !string.IsNullOrWhiteSpace(title))
.Take(2)
.ToArray();
if (related.Length > 0)
{
return $"Top evidence points to {topCard.Title}. Related high-confidence matches also include {string.Join(", ", related)}.";
}
}
var snippet = topCard.Snippet?.Trim();
return string.IsNullOrWhiteSpace(snippet) ? topCard.Title : snippet;
}
private static string BuildGroundedReason(QueryPlan plan, AmbientContext? ambient, EntityCard topCard)
private static string BuildGroundedReason(
QueryPlan plan,
AmbientContext? ambient,
IReadOnlyList<EntityCard> answerCards,
UnifiedSearchCoverage? coverage,
UnifiedSearchOverflow? overflow)
{
var scope = ResolveContextDomain(plan, [topCard], ambient) ?? topCard.Domain;
var topCard = answerCards[0];
var scope = coverage?.CurrentScopeDomain
?? ResolveContextDomain(plan, [topCard], ambient)
?? topCard.Domain;
if (answerCards.Count > 1)
{
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)
{
return $"Current-scope weighting kept {DescribeDomain(scope)} 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.";
}
private static string BuildGroundedEvidence(IReadOnlyList<EntityCard> cards, SynthesisResult? synthesis)
private static string BuildGroundedEvidence(
IReadOnlyList<EntityCard> visibleCards,
IReadOnlyList<EntityCard> answerCards,
SynthesisResult? synthesis,
UnifiedSearchCoverage? coverage,
UnifiedSearchOverflow? overflow)
{
var sourceCount = Math.Max(synthesis?.SourceCount ?? 0, cards.Count);
var sourceCount = Math.Max(synthesis?.SourceCount ?? 0, visibleCards.Count);
var domains = synthesis?.DomainsCovered is { Count: > 0 }
? synthesis.DomainsCovered
: cards.Select(static card => card.Domain)
: answerCards.Select(static card => card.Domain)
.Where(static domain => !string.IsNullOrWhiteSpace(domain))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (overflow is not null && coverage?.CurrentScopeDomain is { Length: > 0 } currentScopeDomain)
{
return $"Grounded in {sourceCount} source(s) across {FormatDomainList(domains)} with {DescribeDomain(currentScopeDomain)} weighted first.";
}
return $"Grounded in {sourceCount} source(s) across {FormatDomainList(domains)}.";
}
@@ -826,6 +1164,28 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value));
}
private static string? ResolveAmbientScopeDomain(AmbientContext? ambient)
{
var routeDomain = AmbientContextProcessor.ResolveDomainFromRoute(ambient?.CurrentRoute ?? string.Empty);
if (!string.IsNullOrWhiteSpace(routeDomain))
{
return routeDomain;
}
if (!string.IsNullOrWhiteSpace(ambient?.LastAction?.Domain))
{
return ambient.LastAction.Domain.Trim().ToLowerInvariant();
}
var actionRouteDomain = AmbientContextProcessor.ResolveDomainFromRoute(ambient?.LastAction?.Route ?? string.Empty);
if (!string.IsNullOrWhiteSpace(actionRouteDomain))
{
return actionRouteDomain;
}
return null;
}
private static string DescribeDomain(string domain)
{
return domain switch
@@ -1406,26 +1766,34 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
QueryPlan plan,
AmbientContext? ambient,
IReadOnlyList<EntityCard> cards,
UnifiedSearchOverflow? overflow,
SynthesisResult? synthesis,
IReadOnlyList<SearchSuggestion>? suggestions,
IReadOnlyList<SearchRefinement>? refinements)
IReadOnlyList<SearchRefinement>? refinements,
UnifiedSearchCoverage? coverage)
{
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
if (cards.Count > 0)
var visibleCards = BuildVisibleAnswerCards(cards, overflow);
if (visibleCards.Count > 0)
{
var topCard = cards[0];
var citations = BuildContextAnswerCitations(cards, synthesis);
var answerCards = SelectDominantAnswerCards(visibleCards);
var topCard = answerCards[0];
var citations = BuildContextAnswerCitations(answerCards, synthesis);
var questions = BuildGroundedQuestions(query, plan, ambient, topCard);
return new ContextAnswer(
Status: "grounded",
Code: "retrieved_evidence",
Summary: BuildGroundedSummary(cards, synthesis),
Reason: BuildGroundedReason(plan, ambient, topCard),
Evidence: BuildGroundedEvidence(cards, synthesis),
Code: answerCards.Count > 1
? "retrieved_blended_evidence"
: overflow is not null
? "retrieved_scope_weighted_evidence"
: "retrieved_evidence",
Summary: BuildGroundedSummary(answerCards, synthesis),
Reason: BuildGroundedReason(plan, ambient, answerCards, coverage, overflow),
Evidence: BuildGroundedEvidence(visibleCards, answerCards, synthesis, coverage, overflow),
Citations: citations,
Questions: questions);
}
@@ -1581,7 +1949,11 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
continue;
}
suggestions.Add(new SearchSuggestion(text, $"Similar to \"{query}\""));
suggestions.Add(new SearchSuggestion(
Text: text,
Reason: $"Similar to \"{query}\"",
Domain: GetDomain(row),
CandidateCount: 1));
if (suggestions.Count >= maxSuggestions)
{
@@ -1672,7 +2044,11 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
/// Sprint: G10-004
/// </summary>
private async Task<IReadOnlyList<SearchRefinement>?> GenerateRefinementsAsync(
string tenantId, string query, int resultCount, CancellationToken ct)
string tenantId,
string query,
int resultCount,
KnowledgeSearchFilter? storeFilter,
CancellationToken ct)
{
if (resultCount >= RefinementResultThreshold)
{
@@ -1705,9 +2081,14 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
var text = alert.Resolution.Trim();
if (text.Length > 120) text = text[..120].TrimEnd();
if (seen.Add(text))
var probe = await ProbeCandidateAsync(text, storeFilter, refinementCt).ConfigureAwait(false);
if (probe is not null && seen.Add(text))
{
refinements.Add(new SearchRefinement(text, "resolved_alert"));
refinements.Add(new SearchRefinement(
Text: text,
Source: "resolved_alert",
Domain: probe.Value.Domain,
CandidateCount: probe.Value.CandidateCount));
}
}
@@ -1721,9 +2102,14 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
{
if (refinements.Count >= maxRefinements) break;
if (seen.Add(similarQuery))
var probe = await ProbeCandidateAsync(similarQuery, storeFilter, refinementCt).ConfigureAwait(false);
if (probe is not null && seen.Add(similarQuery))
{
refinements.Add(new SearchRefinement(similarQuery, "similar_successful_query"));
refinements.Add(new SearchRefinement(
Text: similarQuery,
Source: "similar_successful_query",
Domain: probe.Value.Domain,
CandidateCount: probe.Value.CandidateCount));
}
}
}
@@ -1737,9 +2123,19 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
{
if (refinements.Count >= maxRefinements) break;
if (!string.IsNullOrWhiteSpace(entityKey) && seen.Add(entityKey))
if (string.IsNullOrWhiteSpace(entityKey))
{
refinements.Add(new SearchRefinement(entityKey, "entity_alias"));
continue;
}
var probe = await ProbeCandidateAsync(entityKey, storeFilter, refinementCt).ConfigureAwait(false);
if (probe is not null && seen.Add(entityKey))
{
refinements.Add(new SearchRefinement(
Text: entityKey,
Source: "entity_alias",
Domain: probe.Value.Domain,
CandidateCount: probe.Value.CandidateCount));
}
}
}
@@ -1759,6 +2155,42 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return refinements.Count > 0 ? refinements : null;
}
private async Task<(string Domain, int CandidateCount)?> ProbeCandidateAsync(
string candidateQuery,
KnowledgeSearchFilter? storeFilter,
CancellationToken cancellationToken)
{
var normalized = KnowledgeSearchText.NormalizeWhitespace(candidateQuery);
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
var timeout = TimeSpan.FromMilliseconds(Math.Clamp(_options.QueryTimeoutMs / 6, 50, 500));
var rows = await _store.SearchFtsAsync(
normalized,
storeFilter,
3,
timeout,
cancellationToken).ConfigureAwait(false);
if (rows.Count == 0)
{
return null;
}
var dominantDomain = rows
.GroupBy(GetDomain, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(static group => group.Count())
.ThenBy(static group => group.Key, StringComparer.Ordinal)
.Select(static group => group.Key)
.FirstOrDefault();
return dominantDomain is null
? null
: (dominantDomain, rows.Count);
}
/// <summary>
/// Computes Jaccard similarity over character trigrams of two strings.
/// Used as an in-memory approximation of PostgreSQL pg_trgm similarity().

View File

@@ -79,6 +79,31 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
payload.ContextAnswer.Should().NotBeNull();
payload.ContextAnswer!.Status.Should().Be("grounded");
payload.ContextAnswer.Citations.Should().NotBeNullOrEmpty();
payload.Coverage.Should().NotBeNull();
payload.Coverage!.Domains.Should().Contain(domain => domain.Domain == "findings");
}
[Fact]
public async Task EvaluateSuggestions_WithOperateScope_ReturnsViabilityAndCoverage()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
var response = await client.PostAsJsonAsync("/v1/search/suggestions/evaluate", new UnifiedSearchSuggestionViabilityApiRequest
{
Queries = ["database connectivity"]
});
response.StatusCode.Should().Be(HttpStatusCode.OK);
var payload = await response.Content.ReadFromJsonAsync<UnifiedSearchSuggestionViabilityApiResponse>();
payload.Should().NotBeNull();
payload!.Suggestions.Should().ContainSingle();
payload.Suggestions[0].Viable.Should().BeTrue();
payload.Suggestions[0].Status.Should().Be("grounded");
payload.Coverage.Should().NotBeNull();
payload.Coverage!.Domains.Should().Contain(domain => domain.Domain == "knowledge");
}
[Fact]
@@ -360,6 +385,50 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
Questions:
[
new ContextAnswerQuestion("What evidence proves exploitability for CVE-2024-21626?", "follow_up")
]),
Coverage: new UnifiedSearchCoverage(
CurrentScopeDomain: "findings",
CurrentScopeWeighted: true,
Domains:
[
new UnifiedSearchDomainCoverage(
Domain: "findings",
CandidateCount: 1,
VisibleCardCount: 1,
TopScore: 1.25,
IsCurrentScope: true,
HasVisibleResults: true)
])));
}
public Task<SearchSuggestionViabilityResponse> EvaluateSuggestionsAsync(
SearchSuggestionViabilityRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SearchSuggestionViabilityResponse(
Suggestions:
[
new SearchSuggestionViabilityResult(
Query: request.Queries.FirstOrDefault() ?? "database connectivity",
Viable: true,
Status: "grounded",
Code: "retrieved_evidence",
CardCount: 1,
LeadingDomain: "knowledge",
Reason: "Grounded knowledge evidence is available.")
],
Coverage: new UnifiedSearchCoverage(
CurrentScopeDomain: "knowledge",
CurrentScopeWeighted: true,
Domains:
[
new UnifiedSearchDomainCoverage(
Domain: "knowledge",
CandidateCount: 1,
VisibleCardCount: 1,
TopScore: 1.0,
IsCurrentScope: true,
HasVisibleResults: true)
])));
}
}

View File

@@ -2476,6 +2476,36 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
Mode: "fts-only"),
suggestions));
}
public Task<SearchSuggestionViabilityResponse> EvaluateSuggestionsAsync(
SearchSuggestionViabilityRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SearchSuggestionViabilityResponse(
Suggestions: request.Queries
.Select(static query => new SearchSuggestionViabilityResult(
Query: query,
Viable: true,
Status: "grounded",
Code: "retrieved_evidence",
CardCount: 1,
LeadingDomain: "knowledge",
Reason: "Stubbed search evidence is available."))
.ToArray(),
Coverage: new UnifiedSearchCoverage(
CurrentScopeDomain: "knowledge",
CurrentScopeWeighted: true,
Domains:
[
new UnifiedSearchDomainCoverage(
Domain: "knowledge",
CandidateCount: 1,
VisibleCardCount: 1,
TopScore: 1.0,
IsCurrentScope: true,
HasVisibleResults: true)
])));
}
}
private sealed class SprintStubUnifiedSearchIndexer : IUnifiedSearchIndexer

View File

@@ -32,7 +32,7 @@ public sealed class AmbientContextProcessorTests
CurrentRoute = "/ops/graph/nodes/node-1"
});
boosted["graph"].Should().BeApproximately(1.10, 0.0001);
boosted["graph"].Should().BeApproximately(1.35, 0.0001);
boosted["knowledge"].Should().Be(1.0);
}
@@ -56,8 +56,8 @@ public sealed class AmbientContextProcessorTests
}
});
boosted["knowledge"].Should().BeApproximately(1.10, 0.0001);
boosted["policy"].Should().BeApproximately(1.05, 0.0001);
boosted["knowledge"].Should().BeApproximately(1.35, 0.0001);
boosted["policy"].Should().BeApproximately(1.15, 0.0001);
}
[Fact]

View File

@@ -84,6 +84,99 @@ public sealed class UnifiedSearchServiceTests
result.ContextAnswer.Questions.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SearchAsync_keeps_current_scope_primary_and_surfaces_strong_related_overflow()
{
var doctorRow = MakeRow(
"chunk-doctor-overflow",
"doctor_check",
"PostgreSQL connectivity",
JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"),
snippet: "Doctor knowledge indicates the primary database is unreachable.");
var findingsEmbedding = new float[64];
findingsEmbedding[0] = 0.7f;
findingsEmbedding[1] = 0.2f;
var findingRow = MakeRow(
"chunk-find-overflow",
"finding",
"CVE-2026-9001 in postgres sidecar",
JsonDocument.Parse("{\"domain\":\"findings\",\"cveId\":\"CVE-2026-9001\",\"severity\":\"high\"}"),
embedding: findingsEmbedding,
snippet: "A related finding also points at database-side compromise risk.");
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { doctorRow, findingRow });
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { findingRow });
var service = CreateService(storeMock: storeMock);
var result = await service.SearchAsync(
new UnifiedSearchRequest(
"database connectivity",
Ambient: new AmbientContext
{
CurrentRoute = "/ops/operations/doctor"
}),
CancellationToken.None);
result.Cards.Should().ContainSingle();
result.Cards[0].Domain.Should().Be("knowledge");
result.Overflow.Should().NotBeNull();
result.Overflow!.CurrentScopeDomain.Should().Be("knowledge");
result.Overflow.Cards.Should().ContainSingle();
result.Overflow.Cards[0].Domain.Should().Be("findings");
result.Coverage.Should().NotBeNull();
result.Coverage!.Domains.Should().Contain(domain =>
domain.Domain == "knowledge" && domain.IsCurrentScope);
result.ContextAnswer.Should().NotBeNull();
result.ContextAnswer!.Code.Should().Be("retrieved_scope_weighted_evidence");
}
[Fact]
public async Task SearchAsync_blends_close_top_results_into_one_answer()
{
var doctorRow = MakeRow(
"chunk-doctor-blend",
"doctor_check",
"PostgreSQL connectivity",
JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"),
snippet: "Database connectivity is degraded.");
var guideRow = MakeRow(
"chunk-guide-blend",
"md_section",
"Database failover playbook",
JsonDocument.Parse("{\"domain\":\"knowledge\",\"path\":\"docs/database-failover.md\"}"),
snippet: "Failover guidance covers the same connectivity symptoms.");
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { doctorRow, guideRow });
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var service = CreateService(storeMock: storeMock);
var result = await service.SearchAsync(
new UnifiedSearchRequest("database connectivity", IncludeSynthesis: false),
CancellationToken.None);
result.ContextAnswer.Should().NotBeNull();
result.ContextAnswer!.Code.Should().Be("retrieved_blended_evidence");
result.ContextAnswer.Summary.Should().Contain("PostgreSQL connectivity");
result.ContextAnswer.Summary.Should().Contain("Database failover playbook");
result.ContextAnswer.Citations.Should().HaveCount(2);
}
[Fact]
public async Task SearchAsync_returns_clarify_context_answer_for_broad_query_without_matches()
{
@@ -220,6 +313,74 @@ public sealed class UnifiedSearchServiceTests
answerFrame.SessionId.Should().Be(SearchAnalyticsPrivacy.HashSessionId("tenant-telemetry", "session-456"));
}
[Fact]
public async Task EvaluateSuggestionsAsync_returns_viability_without_recording_answer_frame_analytics()
{
var analyticsOptions = Options.Create(new KnowledgeSearchOptions
{
Enabled = true,
ConnectionString = "Host=localhost;Database=test"
});
var analyticsService = new SearchAnalyticsService(analyticsOptions, NullLogger<SearchAnalyticsService>.Instance);
var qualityMonitor = new SearchQualityMonitor(analyticsOptions, NullLogger<SearchQualityMonitor>.Instance, analyticsService);
var knowledgeRow = MakeRow(
"chunk-doctor-viability",
"doctor_check",
"PostgreSQL connectivity",
JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"));
var storeMock = new Mock<IKnowledgeSearchStore>();
storeMock.Setup(s => s.SearchFtsAsync(
"database connectivity", It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
.ReturnsAsync(new List<KnowledgeChunkRow> { knowledgeRow });
storeMock.Setup(s => s.SearchFtsAsync(
It.Is<string>(query => query != "database connectivity"), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
.ReturnsAsync([]);
storeMock.Setup(s => s.SearchFuzzyAsync(
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<double>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var service = CreateService(
storeMock: storeMock,
analyticsService: analyticsService,
qualityMonitor: qualityMonitor);
var result = await service.EvaluateSuggestionsAsync(
new SearchSuggestionViabilityRequest(
Queries:
[
"database connectivity",
"manually compare unavailable evidence across environments"
],
Ambient: new AmbientContext
{
CurrentRoute = "/ops/operations/doctor"
}),
CancellationToken.None);
result.Suggestions.Should().HaveCount(2);
result.Suggestions.Should().Contain(suggestion =>
suggestion.Query == "database connectivity"
&& suggestion.Viable
&& suggestion.Status == "grounded");
result.Suggestions.Should().Contain(suggestion =>
suggestion.Query == "manually compare unavailable evidence across environments"
&& !suggestion.Viable
&& suggestion.Status == "insufficient");
result.Coverage.CurrentScopeDomain.Should().Be("knowledge");
var events = analyticsService.GetFallbackEventsSnapshot("global", TimeSpan.FromMinutes(1));
events.Should().BeEmpty();
}
[Fact]
public async Task SearchAsync_returns_empty_when_tenant_feature_flag_disables_search()
{