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