Refine unified search answer shaping and viability

This commit is contained in:
master
2026-03-07 21:49:10 +02:00
parent 8f43378317
commit bbfa27ca39
15 changed files with 719 additions and 35 deletions

View File

@@ -0,0 +1,12 @@
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
internal interface IKnowledgeSearchCorpusAvailabilityStore
{
Task<IReadOnlyList<KnowledgeSearchDomainCorpusAvailability>> GetDomainCorpusAvailabilityAsync(
KnowledgeSearchFilter? filters,
CancellationToken cancellationToken);
}
internal sealed record KnowledgeSearchDomainCorpusAvailability(
string Domain,
int ChunkCount);

View File

@@ -100,6 +100,13 @@ public sealed class KnowledgeSearchOptions
/// </summary>
public bool SearchQualityMonitorEnabled { get; set; } = true;
/// <summary>
/// Enables optional analytics/feedback telemetry for search quality and ranking feedback loops.
/// When false, search retrieval, suggestions, and history remain functional but telemetry events
/// and feedback persistence are skipped.
/// </summary>
public bool SearchTelemetryEnabled { get; set; } = true;
/// <summary>
/// Interval in seconds for quality-monitor refresh.
/// </summary>

View File

@@ -7,7 +7,7 @@ using System.Text.Json;
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
internal sealed class PostgresKnowledgeSearchStore : IKnowledgeSearchStore, IAsyncDisposable
internal sealed class PostgresKnowledgeSearchStore : IKnowledgeSearchStore, IKnowledgeSearchCorpusAvailabilityStore, IAsyncDisposable
{
private static readonly JsonDocument EmptyJsonDocument = JsonDocument.Parse("{}");
@@ -399,6 +399,75 @@ internal sealed class PostgresKnowledgeSearchStore : IKnowledgeSearchStore, IAsy
return await ReadChunkRowsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<KnowledgeSearchDomainCorpusAvailability>> GetDomainCorpusAvailabilityAsync(
KnowledgeSearchFilter? filters,
CancellationToken cancellationToken)
{
if (!IsConfigured())
{
return [];
}
var kinds = ResolveKinds(filters);
var tags = ResolveTags(filters);
var normalizedProduct = NormalizeOptional(filters?.Product);
var normalizedVersion = NormalizeOptional(filters?.Version);
var normalizedService = NormalizeOptional(filters?.Service);
var normalizedTenant = NormalizeOptional(filters?.Tenant);
const string sql = """
SELECT
COALESCE(NULLIF(lower(c.metadata->>'domain'), ''), 'knowledge') AS domain,
COUNT(*)::integer AS chunk_count
FROM advisoryai.kb_chunk AS c
INNER JOIN advisoryai.kb_doc AS d
ON d.doc_id = c.doc_id
WHERE (@kind_count = 0 OR c.kind = ANY(@kinds))
AND (@tag_count = 0 OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text(COALESCE(c.metadata->'tags', '[]'::jsonb)) AS tag(value)
WHERE lower(tag.value) = ANY(@tags)
))
AND (@product = '' OR lower(d.product) = lower(@product))
AND (@version = '' OR lower(d.version) = lower(@version))
AND (@service = '' OR lower(COALESCE(c.metadata->>'service', '')) = lower(@service))
AND (
@tenant = ''
OR lower(COALESCE(c.metadata->>'tenant', 'global')) = lower(@tenant)
OR lower(COALESCE(c.metadata->>'tenant', 'global')) = 'global'
)
GROUP BY 1
ORDER BY chunk_count DESC, domain ASC;
""";
await using var command = CreateCommand(sql, TimeSpan.FromSeconds(2));
command.Parameters.AddWithValue("kind_count", kinds.Length);
command.Parameters.AddWithValue(
"kinds",
NpgsqlDbType.Array | NpgsqlDbType.Text,
kinds.Length == 0 ? Array.Empty<string>() : kinds);
command.Parameters.AddWithValue("tag_count", tags.Length);
command.Parameters.AddWithValue(
"tags",
NpgsqlDbType.Array | NpgsqlDbType.Text,
tags.Length == 0 ? Array.Empty<string>() : tags);
command.Parameters.AddWithValue("product", normalizedProduct);
command.Parameters.AddWithValue("version", normalizedVersion);
command.Parameters.AddWithValue("service", normalizedService);
command.Parameters.AddWithValue("tenant", normalizedTenant);
var domains = new List<KnowledgeSearchDomainCorpusAvailability>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
domains.Add(new KnowledgeSearchDomainCorpusAvailability(
reader.GetString(0),
reader.GetInt32(1)));
}
return domains;
}
public async ValueTask DisposeAsync()
{
if (_dataSource.IsValueCreated && _dataSource.Value is not null)

View File

@@ -16,6 +16,7 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
| 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_20260307_033-AI-ZL | DONE | Unified search now derives answer blending from query/context, exposes grounded-only suggestion viability with corpus-readiness states, and keeps analytics/feedback telemetry fully optional. |
| SPRINT_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

@@ -23,6 +23,11 @@ internal sealed class SearchAnalyticsService
public async Task RecordEventAsync(SearchAnalyticsEvent evt, CancellationToken ct = default)
{
if (!_options.SearchTelemetryEnabled)
{
return;
}
var recordedAt = DateTimeOffset.UtcNow;
var persistedEvent = SanitizeEvent(evt);
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
@@ -67,7 +72,7 @@ internal sealed class SearchAnalyticsService
public async Task RecordEventsAsync(IReadOnlyList<SearchAnalyticsEvent> events, CancellationToken ct = default)
{
if (events.Count == 0)
if (events.Count == 0 || !_options.SearchTelemetryEnabled)
{
return;
}
@@ -127,6 +132,11 @@ internal sealed class SearchAnalyticsService
public async Task<IReadOnlyDictionary<string, int>> GetPopularityMapAsync(string tenantId, int days = 30, CancellationToken ct = default)
{
if (!_options.SearchTelemetryEnabled)
{
return new Dictionary<string, int>(StringComparer.Ordinal);
}
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
return BuildFallbackPopularityMap(tenantId, days);

View File

@@ -44,6 +44,11 @@ internal sealed class SearchQualityMonitor
public async Task StoreFeedbackAsync(SearchFeedbackEntry entry, CancellationToken ct = default)
{
if (!_options.SearchTelemetryEnabled)
{
return;
}
var createdAt = DateTimeOffset.UtcNow;
var persistedEntry = SanitizeFeedbackEntry(entry);
if (string.IsNullOrWhiteSpace(_options.ConnectionString))

View File

@@ -167,7 +167,9 @@ public sealed record SearchSuggestionViabilityResult(
string Code,
int CardCount,
string? LeadingDomain,
string Reason);
string Reason,
string ViabilityState = "no_match",
bool ScopeReady = false);
public sealed record EntityCard
{

View File

@@ -26,6 +26,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
private const int CoverageCandidateWindow = 24;
private const double OverflowScoreBandRatio = 0.04d;
private const double BlendedAnswerScoreBandRatio = 0.025d;
private const double CompareBlendedAnswerScoreBandRatio = 0.06d;
private const double ScopedTroubleshootBlendedAnswerScoreBandRatio = 0.018d;
private readonly KnowledgeSearchOptions _options;
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly IKnowledgeSearchStore _store;
@@ -68,6 +70,22 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
SuggestionPreflight
}
private sealed record CorpusAvailabilitySnapshot(
bool Known,
string? CurrentScopeDomain,
int CurrentScopeChunkCount,
int TotalChunkCount,
IReadOnlyDictionary<string, int> DomainChunkCounts)
{
public static CorpusAvailabilitySnapshot Unknown(string? currentScopeDomain) =>
new(
Known: false,
CurrentScopeDomain: currentScopeDomain,
CurrentScopeChunkCount: 0,
TotalChunkCount: 0,
DomainChunkCounts: new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase));
}
public UnifiedSearchService(
IOptions<KnowledgeSearchOptions> options,
IKnowledgeSearchStore store,
@@ -136,6 +154,11 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return new SearchSuggestionViabilityResponse([], fallbackCoverage);
}
var storeFilter = BuildStoreFilter(request.Filters);
var corpusAvailability = await LoadCorpusAvailabilityAsync(
storeFilter,
request.Ambient,
cancellationToken).ConfigureAwait(false);
var results = new List<SearchSuggestionViabilityResult>(normalizedQueries.Length);
UnifiedSearchCoverage? aggregateCoverage = null;
@@ -149,16 +172,18 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
IncludeSynthesis: false,
IncludeDebug: false,
Ambient: request.Ambient),
cancellationToken,
SearchExecutionKind.SuggestionPreflight).ConfigureAwait(false);
cancellationToken,
SearchExecutionKind.SuggestionPreflight,
corpusAvailability).ConfigureAwait(false);
aggregateCoverage = MergeCoverage(aggregateCoverage, response.Coverage);
var cardCount = response.Cards.Count + (response.Overflow?.Cards.Count ?? 0);
var answer = response.ContextAnswer;
var viabilityState = DetermineSuggestionViabilityState(cardCount, answer, corpusAvailability);
results.Add(new SearchSuggestionViabilityResult(
Query: query,
Viable: cardCount > 0 || string.Equals(answer?.Status, "clarify", StringComparison.OrdinalIgnoreCase),
Viable: IsSuggestionViable(viabilityState),
Status: answer?.Status ?? "insufficient",
Code: answer?.Code ?? "no_grounded_evidence",
CardCount: cardCount,
@@ -166,7 +191,9 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
?? 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."));
Reason: BuildSuggestionViabilityReason(answer, viabilityState, corpusAvailability),
ViabilityState: viabilityState,
ScopeReady: IsCurrentScopeReady(corpusAvailability)));
}
return new SearchSuggestionViabilityResponse(
@@ -177,7 +204,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
private async Task<UnifiedSearchResponse> SearchAsyncInternal(
UnifiedSearchRequest request,
CancellationToken cancellationToken,
SearchExecutionKind executionKind)
SearchExecutionKind executionKind,
CorpusAvailabilitySnapshot? corpusAvailabilityOverride = null)
{
ArgumentNullException.ThrowIfNull(request);
@@ -241,12 +269,14 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
ContextEntityBoosts = contextEntityBoosts
};
var storeFilter = BuildStoreFilter(request.Filters);
var currentScopeDomain = ResolveAmbientScopeDomain(request.Ambient);
var corpusAvailability = corpusAvailabilityOverride
?? await LoadCorpusAvailabilityAsync(storeFilter, request.Ambient, cancellationToken).ConfigureAwait(false);
var topK = ResolveTopK(request.K);
var timeout = TimeSpan.FromMilliseconds(Math.Max(250, _options.QueryTimeoutMs));
// Build domain-aware filter for the store query
var storeFilter = BuildStoreFilter(request.Filters);
var ftsRows = await _store.SearchFtsAsync(
query,
storeFilter,
@@ -364,7 +394,6 @@ 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);
@@ -402,7 +431,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
synthesis,
suggestions,
refinements,
coverage);
coverage,
corpusAvailability);
var totalVisibleCardCount = primaryCards.Count + (overflow?.Cards.Count ?? 0);
var response = new UnifiedSearchResponse(
query,
@@ -561,7 +591,10 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
.ToArray();
}
private static IReadOnlyList<EntityCard> SelectDominantAnswerCards(IReadOnlyList<EntityCard> visibleCards)
private static IReadOnlyList<EntityCard> SelectDominantAnswerCards(
IReadOnlyList<EntityCard> visibleCards,
QueryPlan plan,
AmbientContext? ambient)
{
if (visibleCards.Count <= 1)
{
@@ -569,14 +602,44 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
}
var topScore = Math.Max(Math.Abs(visibleCards[0].Score), 0.000001d);
var scoreBandRatio = ResolveBlendedAnswerScoreBandRatio(plan, ambient, visibleCards);
var blended = visibleCards
.Where(card => (topScore - card.Score) / topScore <= BlendedAnswerScoreBandRatio)
.Where(card => (topScore - card.Score) / topScore <= scoreBandRatio)
.Take(MaxContextAnswerCitations)
.ToArray();
return blended.Length >= 2
? blended
: visibleCards.Take(1).ToArray();
if (blended.Length >= 2)
{
return blended;
}
if (string.Equals(plan.Intent, "compare", StringComparison.OrdinalIgnoreCase))
{
return visibleCards
.Take(Math.Min(2, MaxContextAnswerCitations))
.ToArray();
}
return visibleCards.Take(1).ToArray();
}
private static double ResolveBlendedAnswerScoreBandRatio(
QueryPlan plan,
AmbientContext? ambient,
IReadOnlyList<EntityCard> visibleCards)
{
if (string.Equals(plan.Intent, "compare", StringComparison.OrdinalIgnoreCase))
{
return CompareBlendedAnswerScoreBandRatio;
}
if (string.Equals(plan.Intent, "troubleshoot", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(ResolveContextDomain(plan, visibleCards, ambient)))
{
return ScopedTroubleshootBlendedAnswerScoreBandRatio;
}
return BlendedAnswerScoreBandRatio;
}
private static UnifiedSearchCoverage BuildCoverage(
@@ -974,7 +1037,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
private static string BuildInsufficientEvidence(
IReadOnlyList<SearchSuggestion>? suggestions,
IReadOnlyList<SearchRefinement>? refinements)
IReadOnlyList<SearchRefinement>? refinements,
CorpusAvailabilitySnapshot corpusAvailability)
{
var suggestionCount = suggestions?.Count ?? 0;
var refinementCount = refinements?.Count ?? 0;
@@ -983,9 +1047,70 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return $"No grounded citations were found, but {suggestionCount + refinementCount} recovery hint(s) are available.";
}
if (corpusAvailability.Known && corpusAvailability.TotalChunkCount > 0)
{
return "The ingested corpus is available, but this query did not retrieve grounded citations or result cards.";
}
return "No grounded citations, result cards, or bounded recovery hints were retrieved.";
}
private static string BuildInsufficientReason(QueryPlan plan, AmbientContext? ambient)
{
var scope = ResolveContextDomain(plan, [], ambient) ?? "current scope";
return $"No grounded evidence matched the requested terms inside {DescribeDomain(scope)} or nearby weighted domains.";
}
private static string BuildCorpusUnreadySummary(
string query,
QueryPlan? plan,
AmbientContext? ambient,
CorpusAvailabilitySnapshot corpusAvailability)
{
var scope = corpusAvailability.CurrentScopeDomain
?? ResolveContextDomain(plan, [], ambient)
?? "current scope";
if (corpusAvailability.Known && corpusAvailability.TotalChunkCount == 0)
{
return $"\"{query}\" could not be answered because the search corpus is not populated yet.";
}
return $"\"{query}\" could not be answered because {DescribeDomain(scope)} is not populated in search yet.";
}
private static string BuildCorpusUnreadyReason(
QueryPlan? plan,
AmbientContext? ambient,
CorpusAvailabilitySnapshot corpusAvailability)
{
var scope = corpusAvailability.CurrentScopeDomain
?? ResolveContextDomain(plan, [], ambient)
?? "current scope";
if (corpusAvailability.Known && corpusAvailability.TotalChunkCount == 0)
{
return "No ingested search corpus is available yet, so the query cannot produce grounded results.";
}
return $"The active route maps to {DescribeDomain(scope)}, but that scope has no ingested search corpus yet.";
}
private static string BuildCorpusUnreadyEvidence(CorpusAvailabilitySnapshot corpusAvailability)
{
if (corpusAvailability.Known && corpusAvailability.TotalChunkCount == 0)
{
return "No indexed chunks are currently available for the active tenant and filter set.";
}
if (!string.IsNullOrWhiteSpace(corpusAvailability.CurrentScopeDomain))
{
return $"{DescribeDomain(corpusAvailability.CurrentScopeDomain)} currently has zero indexed chunks for the active tenant and filter set.";
}
return "The active scope is not populated in the indexed corpus.";
}
private static IReadOnlyList<string> GetGroundedQuestionTemplates(string domain, string title)
{
return domain switch
@@ -1186,6 +1311,117 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return null;
}
private async Task<CorpusAvailabilitySnapshot> LoadCorpusAvailabilityAsync(
KnowledgeSearchFilter? storeFilter,
AmbientContext? ambient,
CancellationToken cancellationToken)
{
var currentScopeDomain = ResolveAmbientScopeDomain(ambient);
if (_store is not IKnowledgeSearchCorpusAvailabilityStore availabilityStore)
{
return CorpusAvailabilitySnapshot.Unknown(currentScopeDomain);
}
var domains = await availabilityStore.GetDomainCorpusAvailabilityAsync(
storeFilter,
cancellationToken).ConfigureAwait(false);
var domainCounts = domains
.Where(static entry => !string.IsNullOrWhiteSpace(entry.Domain))
.GroupBy(static entry => entry.Domain.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(
static group => group.Key,
static group => group.Max(static entry => Math.Max(0, entry.ChunkCount)),
StringComparer.OrdinalIgnoreCase);
var currentScopeChunkCount = !string.IsNullOrWhiteSpace(currentScopeDomain)
&& domainCounts.TryGetValue(currentScopeDomain, out var scopedCount)
? scopedCount
: 0;
return new CorpusAvailabilitySnapshot(
Known: true,
CurrentScopeDomain: currentScopeDomain,
CurrentScopeChunkCount: currentScopeChunkCount,
TotalChunkCount: domainCounts.Values.Sum(),
DomainChunkCounts: domainCounts);
}
private static string DetermineSuggestionViabilityState(
int cardCount,
ContextAnswer? answer,
CorpusAvailabilitySnapshot corpusAvailability)
{
if (cardCount > 0)
{
return "grounded";
}
if (string.Equals(answer?.Status, "clarify", StringComparison.OrdinalIgnoreCase))
{
return "needs_clarification";
}
var unreadyCode = ResolveCorpusUnreadyCode(corpusAvailability);
if (string.Equals(unreadyCode, "search_corpus_unready", StringComparison.Ordinal))
{
return "corpus_unready";
}
if (string.Equals(unreadyCode, "current_scope_corpus_unready", StringComparison.Ordinal))
{
return "scope_unready";
}
return "no_match";
}
private static bool IsSuggestionViable(string viabilityState)
{
return string.Equals(viabilityState, "grounded", StringComparison.OrdinalIgnoreCase);
}
private static bool IsCurrentScopeReady(CorpusAvailabilitySnapshot corpusAvailability)
{
return string.IsNullOrWhiteSpace(ResolveCorpusUnreadyCode(corpusAvailability));
}
private static string BuildSuggestionViabilityReason(
ContextAnswer? answer,
string viabilityState,
CorpusAvailabilitySnapshot corpusAvailability)
{
return viabilityState switch
{
"grounded" => answer?.Reason ?? "Grounded evidence is available for this suggestion.",
"needs_clarification" => answer?.Reason ?? "The query is too broad to surface as a ready-made suggestion.",
"scope_unready" => BuildCorpusUnreadyReason(plan: null, ambient: null, corpusAvailability),
"corpus_unready" => BuildCorpusUnreadyReason(plan: null, ambient: null, corpusAvailability),
_ => answer?.Reason ?? "No grounded evidence matched this suggestion in the current corpus."
};
}
private static string? ResolveCorpusUnreadyCode(CorpusAvailabilitySnapshot corpusAvailability)
{
if (!corpusAvailability.Known)
{
return null;
}
if (corpusAvailability.TotalChunkCount <= 0)
{
return "search_corpus_unready";
}
if (!string.IsNullOrWhiteSpace(corpusAvailability.CurrentScopeDomain)
&& corpusAvailability.CurrentScopeChunkCount <= 0)
{
return "current_scope_corpus_unready";
}
return null;
}
private static string DescribeDomain(string domain)
{
return domain switch
@@ -1770,7 +2006,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
SynthesisResult? synthesis,
IReadOnlyList<SearchSuggestion>? suggestions,
IReadOnlyList<SearchRefinement>? refinements,
UnifiedSearchCoverage? coverage)
UnifiedSearchCoverage? coverage,
CorpusAvailabilitySnapshot corpusAvailability)
{
if (string.IsNullOrWhiteSpace(query))
{
@@ -1780,7 +2017,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
var visibleCards = BuildVisibleAnswerCards(cards, overflow);
if (visibleCards.Count > 0)
{
var answerCards = SelectDominantAnswerCards(visibleCards);
var answerCards = SelectDominantAnswerCards(visibleCards, plan, ambient);
var topCard = answerCards[0];
var citations = BuildContextAnswerCitations(answerCards, synthesis);
var questions = BuildGroundedQuestions(query, plan, ambient, topCard);
@@ -1798,6 +2035,20 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
Questions: questions);
}
var corpusUnreadyCode = ResolveCorpusUnreadyCode(corpusAvailability);
if (!string.IsNullOrWhiteSpace(corpusUnreadyCode))
{
var unreadyQuestions = BuildRecoveryQuestions(query, plan, ambient, suggestions, refinements);
return new ContextAnswer(
Status: "insufficient",
Code: corpusUnreadyCode,
Summary: BuildCorpusUnreadySummary(query, plan, ambient, corpusAvailability),
Reason: BuildCorpusUnreadyReason(plan, ambient, corpusAvailability),
Evidence: BuildCorpusUnreadyEvidence(corpusAvailability),
Citations: [],
Questions: unreadyQuestions);
}
if (ShouldClarifyQuery(query, plan, ambient, suggestions, refinements))
{
var clarificationScope = ResolveContextDomain(plan, cards, ambient) ?? "current scope";
@@ -1818,8 +2069,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
Status: "insufficient",
Code: "no_grounded_evidence",
Summary: BuildInsufficientSummary(query, plan, ambient),
Reason: "No grounded evidence matched the requested terms in the current ingested corpus.",
Evidence: BuildInsufficientEvidence(suggestions, refinements),
Reason: BuildInsufficientReason(plan, ambient),
Evidence: BuildInsufficientEvidence(suggestions, refinements, corpusAvailability),
Citations: [],
Questions: recoveryQuestions);
}