Add implicit scope weighting and suggestion viability
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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)
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user