Add grounded unified search answers and live verification

This commit is contained in:
master
2026-03-07 03:55:51 +02:00
parent 2ff0e1f86b
commit edb947d602
19 changed files with 1180 additions and 32 deletions

View File

@@ -10,6 +10,11 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
| SPRINT_20260224_102-G1-005 | DONE | ONNX missing-model fallback integration evidence added (`G1_OnnxEncoderSelection_MissingModelPath_FallsBackToDeterministicHashEncoder`). |
| SPRINT_20260224_102-G1-004 | DONE | Semantic recall benchmark corpus and assertions complete (48 queries; no exact-term regression; semantic recall uplift proven). |
| SPRINT_20260224_102-G1-001 | DOING | ONNX runtime package + license docs completed; model asset provisioning at `models/all-MiniLM-L6-v2.onnx` still pending deployment packaging. |
| AI-SELF-001 | DONE | Unified search now emits the contextual answer payload (`contextAnswer`) with answer state, citations, and follow-up questions for self-serve search. |
| AI-SELF-002 | DONE | Deterministic grounded/clarify/insufficient fallback policy is implemented in unified search orchestration. |
| AI-SELF-003 | DONE | Follow-up question generation from route/domain intent, recent actions, and evidence is implemented. |
| AI-SELF-004 | TODO | Telemetry for unanswered and reformulated self-serve journeys is still pending. |
| AI-SELF-006 | DONE | Live ingestion-backed answer verification succeeded on the Doctor/knowledge route after local rebuild. |
| 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,17 +2,29 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch.Context;
internal sealed class AmbientContextProcessor
{
private const double CurrentRouteBoost = 0.10d;
private const double LastActionDomainBoost = 0.05d;
private const double VisibleEntityBoost = 0.20d;
private const double LastActionEntityBoost = 0.25d;
private static readonly (string Prefix, string Domain)[] RouteDomainMappings =
[
("/console/findings", "findings"),
("/security/triage", "findings"),
("/security/findings", "findings"),
("/security/advisories-vex", "vex"),
("/ops/policies", "policy"),
("/ops/policy", "policy"),
("/ops/graph", "graph"),
("/security/reach", "graph"),
("/ops/audit", "timeline"),
("/ops/timeline", "timeline"),
("/audit", "timeline"),
("/console/scans", "scanner"),
("/ops/operations/jobs", "opsmemory"),
("/ops/operations/scheduler", "opsmemory"),
("/ops/doctor", "knowledge"),
("/ops/operations/doctor", "knowledge"),
("/ops/operations/system-health", "knowledge"),
("/docs", "knowledge")
];
@@ -23,18 +35,26 @@ internal sealed class AmbientContextProcessor
var output = new Dictionary<string, double>(baseWeights, StringComparer.OrdinalIgnoreCase);
if (ambient is null || string.IsNullOrWhiteSpace(ambient.CurrentRoute))
{
var actionOnlyDomain = ResolveDomainFromAmbientAction(ambient?.LastAction);
if (!string.IsNullOrWhiteSpace(actionOnlyDomain))
{
ApplyBoost(output, actionOnlyDomain, LastActionDomainBoost);
}
return output;
}
var domain = ResolveDomainFromRoute(ambient.CurrentRoute);
if (string.IsNullOrWhiteSpace(domain))
if (!string.IsNullOrWhiteSpace(domain))
{
return output;
ApplyBoost(output, domain, CurrentRouteBoost);
}
output[domain] = output.TryGetValue(domain, out var existing)
? existing + 0.10d
: 1.10d;
var actionDomain = ResolveDomainFromAmbientAction(ambient.LastAction);
if (!string.IsNullOrWhiteSpace(actionDomain))
{
ApplyBoost(output, actionDomain, LastActionDomainBoost);
}
return output;
}
@@ -54,12 +74,21 @@ internal sealed class AmbientContextProcessor
continue;
}
map[entityKey.Trim()] = Math.Max(
map.TryGetValue(entityKey.Trim(), out var existing) ? existing : 0d,
0.20d);
var normalized = entityKey.Trim();
map[normalized] = Math.Max(
map.TryGetValue(normalized, out var existing) ? existing : 0d,
VisibleEntityBoost);
}
}
if (!string.IsNullOrWhiteSpace(ambient?.LastAction?.EntityKey))
{
var normalized = ambient.LastAction.EntityKey.Trim();
map[normalized] = Math.Max(
map.TryGetValue(normalized, out var existing) ? existing : 0d,
LastActionEntityBoost);
}
foreach (var entry in session.EntityBoosts)
{
map[entry.Key] = Math.Max(
@@ -120,5 +149,31 @@ internal sealed class AmbientContextProcessor
return null;
}
}
private static string? ResolveDomainFromAmbientAction(AmbientAction? action)
{
if (action is null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(action.Domain))
{
return action.Domain.Trim().ToLowerInvariant();
}
if (!string.IsNullOrWhiteSpace(action.Route))
{
return ResolveDomainFromRoute(action.Route);
}
return null;
}
private static void ApplyBoost(IDictionary<string, double> output, string domain, double amount)
{
output[domain] = output.TryGetValue(domain, out var existing)
? existing + amount
: 1d + amount;
}
}

View File

@@ -69,12 +69,50 @@ public sealed record AmbientContext
public string? SessionId { get; init; }
public bool ResetSession { get; init; }
public AmbientAction? LastAction { get; init; }
}
public sealed record AmbientAction
{
public string Action { get; init; } = string.Empty;
public string? Source { get; init; }
public string? QueryHint { get; init; }
public string? Domain { get; init; }
public string? EntityKey { get; init; }
public string? Route { get; init; }
public DateTimeOffset? OccurredAt { get; init; }
}
public sealed record SearchSuggestion(string Text, string Reason);
public sealed record SearchRefinement(string Text, string Source);
public sealed record ContextAnswer(
string Status,
string Code,
string Summary,
string Reason,
string Evidence,
IReadOnlyList<ContextAnswerCitation>? Citations = null,
IReadOnlyList<ContextAnswerQuestion>? Questions = null);
public sealed record ContextAnswerCitation(
string EntityKey,
string Title,
string Domain,
string? Route = null);
public sealed record ContextAnswerQuestion(
string Query,
string Kind = "follow_up");
public sealed record UnifiedSearchResponse(
string Query,
int TopK,
@@ -82,7 +120,8 @@ public sealed record UnifiedSearchResponse(
SynthesisResult? Synthesis,
UnifiedSearchDiagnostics Diagnostics,
IReadOnlyList<SearchSuggestion>? Suggestions = null,
IReadOnlyList<SearchRefinement>? Refinements = null);
IReadOnlyList<SearchRefinement>? Refinements = null,
ContextAnswer? ContextAnswer = null);
public sealed record EntityCard
{

View File

@@ -18,6 +18,9 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch;
internal sealed class UnifiedSearchService : IUnifiedSearchService
{
private const int MaxContextAnswerCitations = 3;
private const int MaxContextAnswerQuestions = 3;
private const int ClarifyTokenThreshold = 3;
private readonly KnowledgeSearchOptions _options;
private readonly UnifiedSearchOptions _unifiedOptions;
private readonly IKnowledgeSearchStore _store;
@@ -106,7 +109,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
if (query.Length > _unifiedOptions.MaxQueryLength)
{
return EmptyResponse(query, request.K, "query_too_long");
return EmptyResponse(query, request.K, "query_too_long", request.Ambient);
}
var tenantId = request.Filters?.Tenant ?? "global";
@@ -115,7 +118,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
if (!_options.Enabled || !IsSearchEnabledForTenant(tenantFlags) || string.IsNullOrWhiteSpace(_options.ConnectionString))
{
return EmptyResponse(query, request.K, "disabled");
return EmptyResponse(query, request.K, "disabled", request.Ambient);
}
if (request.Ambient?.ResetSession == true &&
@@ -292,6 +295,14 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
}
var duration = _timeProvider.GetUtcNow() - startedAt;
var contextAnswer = BuildContextAnswer(
query,
plan,
request.Ambient,
cards,
synthesis,
suggestions,
refinements);
var response = new UnifiedSearchResponse(
query,
topK,
@@ -307,7 +318,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
plan,
federationDiagnostics),
suggestions,
refinements);
refinements,
contextAnswer);
EmitTelemetry(plan, response, tenantId);
return response;
@@ -370,6 +382,489 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
};
}
private static IReadOnlyList<ContextAnswerCitation> BuildContextAnswerCitations(
IReadOnlyList<EntityCard> cards,
SynthesisResult? synthesis)
{
var citations = new List<ContextAnswerCitation>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (synthesis?.Citations is { Count: > 0 })
{
foreach (var citation in synthesis.Citations)
{
var card = cards.FirstOrDefault(card =>
string.Equals(card.EntityKey, citation.EntityKey, StringComparison.OrdinalIgnoreCase));
if (card is null || !seen.Add(card.EntityKey))
{
continue;
}
citations.Add(new ContextAnswerCitation(
card.EntityKey,
string.IsNullOrWhiteSpace(card.Title) ? citation.Title : card.Title,
card.Domain,
GetPrimaryActionRoute(card)));
if (citations.Count >= MaxContextAnswerCitations)
{
return citations;
}
}
}
foreach (var card in cards)
{
if (!seen.Add(card.EntityKey))
{
continue;
}
citations.Add(new ContextAnswerCitation(
card.EntityKey,
card.Title,
card.Domain,
GetPrimaryActionRoute(card)));
if (citations.Count >= MaxContextAnswerCitations)
{
break;
}
}
return citations;
}
private static string BuildGroundedSummary(IReadOnlyList<EntityCard> cards, SynthesisResult? synthesis)
{
var synthesisSummary = synthesis?.Summary?.Trim();
if (!string.IsNullOrWhiteSpace(synthesisSummary))
{
return synthesisSummary;
}
var topCard = cards[0];
var snippet = topCard.Snippet?.Trim();
return string.IsNullOrWhiteSpace(snippet) ? topCard.Title : snippet;
}
private static string BuildGroundedReason(QueryPlan plan, AmbientContext? ambient, EntityCard topCard)
{
var scope = ResolveContextDomain(plan, [topCard], ambient) ?? topCard.Domain;
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)
{
var sourceCount = Math.Max(synthesis?.SourceCount ?? 0, cards.Count);
var domains = synthesis?.DomainsCovered is { Count: > 0 }
? synthesis.DomainsCovered
: cards.Select(static card => card.Domain)
.Where(static domain => !string.IsNullOrWhiteSpace(domain))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return $"Grounded in {sourceCount} source(s) across {FormatDomainList(domains)}.";
}
private static bool ShouldClarifyQuery(
string query,
QueryPlan plan,
AmbientContext? ambient,
IReadOnlyList<SearchSuggestion>? suggestions,
IReadOnlyList<SearchRefinement>? refinements)
{
if (plan.DetectedEntities.Count > 0)
{
return false;
}
if (refinements is { Count: > 0 } || suggestions is { Count: > 0 })
{
return true;
}
var tokenCount = query.Split(
' ',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length;
if (tokenCount <= ClarifyTokenThreshold)
{
return true;
}
var contextDomain = ResolveContextDomain(plan, [], ambient);
if (!string.IsNullOrWhiteSpace(contextDomain) &&
(string.Equals(plan.Intent, "explore", StringComparison.OrdinalIgnoreCase) ||
string.Equals(plan.Intent, "navigate", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
return false;
}
private static string BuildClarifySummary(string query, string contextDomain)
{
return $"\"{query}\" is too broad for {DescribeDomain(contextDomain)}. Narrow it to a specific target before we answer.";
}
private static string BuildClarifyReason(QueryPlan plan, AmbientContext? ambient)
{
var scope = ResolveContextDomain(plan, [], ambient) ?? "current scope";
return $"No grounded entity was retrieved, and the {plan.Intent} query needs a narrower target in {DescribeDomain(scope)}.";
}
private static string BuildClarifyEvidence(
IReadOnlyList<SearchSuggestion>? suggestions,
IReadOnlyList<SearchRefinement>? refinements,
string contextDomain)
{
var suggestionCount = suggestions?.Count ?? 0;
var refinementCount = refinements?.Count ?? 0;
if (suggestionCount > 0 || refinementCount > 0)
{
return $"{suggestionCount + refinementCount} bounded recovery hint(s) are available for {DescribeDomain(contextDomain)}.";
}
return $"No grounded evidence was retrieved yet from {DescribeDomain(contextDomain)}.";
}
private static IReadOnlyList<ContextAnswerQuestion> BuildGroundedQuestions(
string query,
QueryPlan plan,
AmbientContext? ambient,
EntityCard topCard)
{
var prompts = new List<ContextAnswerQuestion>();
var topTitle = topCard.Title.Trim();
foreach (var question in GetGroundedQuestionTemplates(topCard.Domain, topTitle))
{
prompts.Add(new ContextAnswerQuestion(question, "follow_up"));
}
if (!string.IsNullOrWhiteSpace(ambient?.LastAction?.QueryHint))
{
var lastHint = ambient.LastAction.QueryHint.Trim();
if (!lastHint.Equals(query, StringComparison.OrdinalIgnoreCase))
{
prompts.Add(new ContextAnswerQuestion(
$"How does {topTitle} relate to {lastHint}?",
"follow_up"));
}
}
return DistinctQuestions(prompts);
}
private static IReadOnlyList<ContextAnswerQuestion> BuildClarifyingQuestions(
string query,
QueryPlan? plan,
AmbientContext? ambient)
{
var prompts = new List<ContextAnswerQuestion>();
var domain = ResolveContextDomain(plan, [], ambient);
foreach (var question in GetClarifyingQuestionTemplates(domain))
{
prompts.Add(new ContextAnswerQuestion(question, "clarify"));
}
if (!string.IsNullOrWhiteSpace(ambient?.LastAction?.QueryHint))
{
var lastHint = ambient.LastAction.QueryHint.Trim();
if (!lastHint.Equals(query, StringComparison.OrdinalIgnoreCase))
{
prompts.Add(new ContextAnswerQuestion(
$"Do you want to continue from {lastHint}?",
"clarify"));
}
}
return DistinctQuestions(prompts);
}
private static IReadOnlyList<ContextAnswerQuestion> BuildRecoveryQuestions(
string query,
QueryPlan plan,
AmbientContext? ambient,
IReadOnlyList<SearchSuggestion>? suggestions,
IReadOnlyList<SearchRefinement>? refinements)
{
var prompts = new List<ContextAnswerQuestion>();
if (refinements is { Count: > 0 })
{
prompts.AddRange(refinements.Select(static refinement =>
new ContextAnswerQuestion(refinement.Text, "recover")));
}
if (suggestions is { Count: > 0 })
{
prompts.AddRange(suggestions.Select(static suggestion =>
new ContextAnswerQuestion(suggestion.Text, "recover")));
}
if (prompts.Count == 0)
{
var domain = ResolveContextDomain(plan, [], ambient);
foreach (var question in GetRecoveryQuestionTemplates(domain, query))
{
prompts.Add(new ContextAnswerQuestion(question, "recover"));
}
}
return DistinctQuestions(prompts);
}
private static IReadOnlyList<ContextAnswerQuestion> BuildFallbackQuestionsForDisabledSearch(AmbientContext? ambient)
{
var domain = ResolveContextDomain(plan: null, cards: [], ambient: ambient);
return DistinctQuestions(GetRecoveryQuestionTemplates(domain, "current search")
.Select(static question => new ContextAnswerQuestion(question, "recover"))
.ToList());
}
private static string BuildInsufficientSummary(string query, QueryPlan plan, AmbientContext? ambient)
{
var scope = ResolveContextDomain(plan, [], ambient) ?? "current scope";
return $"No grounded answer was found for \"{query}\" in {DescribeDomain(scope)}.";
}
private static string BuildInsufficientEvidence(
IReadOnlyList<SearchSuggestion>? suggestions,
IReadOnlyList<SearchRefinement>? refinements)
{
var suggestionCount = suggestions?.Count ?? 0;
var refinementCount = refinements?.Count ?? 0;
if (suggestionCount > 0 || refinementCount > 0)
{
return $"No grounded citations were found, but {suggestionCount + refinementCount} recovery hint(s) are available.";
}
return "No grounded citations, result cards, or bounded recovery hints were retrieved.";
}
private static IReadOnlyList<string> GetGroundedQuestionTemplates(string domain, string title)
{
return domain switch
{
"findings" =>
[
$"Why does {title} block release?",
$"What is the safest remediation for {title}?",
$"What evidence proves exploitability for {title}?"
],
"vex" =>
[
$"Why is {title} marked not affected?",
$"What evidence conflicts with {title}?",
$"Which components are covered by {title}?"
],
"policy" =>
[
$"Why is {title} failing?",
$"What findings are impacted by {title}?",
$"What is the safest exception path for {title}?"
],
"graph" =>
[
$"Which path makes {title} reachable?",
$"What is the blast radius of {title}?",
$"What should I inspect next from {title}?"
],
"timeline" =>
[
$"What changed before {title}?",
$"What else happened around {title}?",
$"Which release is most related to {title}?"
],
"opsmemory" =>
[
$"Have we seen {title} before?",
$"What runbook usually resolves {title}?",
$"What repeated failures are related to {title}?"
],
_ =>
[
$"How do I verify the fix for {title}?",
$"What changed before {title} failed?",
$"What should I inspect next for {title}?"
]
};
}
private static IReadOnlyList<string> GetClarifyingQuestionTemplates(string? domain)
{
return domain switch
{
"findings" =>
[
"Which CVE, workload, or package should I narrow this to?",
"Should I focus on reachable, production, or unresolved findings?"
],
"vex" =>
[
"Which statement, component, or product range should I narrow this to?",
"Do you need exploitability meaning, coverage, or conflict evidence?"
],
"policy" =>
[
"Which rule, environment, or control should I narrow this to?",
"Do you need recent failures, exceptions, or promotion impact?"
],
"graph" =>
[
"Which node, package, or edge should I narrow this to?",
"Do you want reachability, impact, or next-step guidance?"
],
"timeline" =>
[
"Which deployment, incident, or time window should I narrow this to?",
"Do you want causes, impacts, or follow-up events?"
],
"opsmemory" =>
[
"Which job, incident, or recurring failure should I narrow this to?",
"Do you want precedent, likely cause, or recommended recovery?"
],
_ =>
[
"Which check, component, or symptom should I narrow this to?",
"Do you want diagnosis, remediation, or verification steps?"
]
};
}
private static IReadOnlyList<string> GetRecoveryQuestionTemplates(string? domain, string query)
{
return domain switch
{
"findings" =>
[
$"reachable {query}",
$"unresolved {query}",
$"critical {query}"
],
"policy" =>
[
$"policy exceptions {query}",
$"failing policy gates {query}",
$"promotion impact {query}"
],
"timeline" =>
[
$"recent events {query}",
$"deployment history {query}",
$"incident timeline {query}"
],
_ =>
[
query,
$"strongest evidence {query}",
$"related {query}"
]
};
}
private static IReadOnlyList<ContextAnswerQuestion> DistinctQuestions(IReadOnlyList<ContextAnswerQuestion> questions)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var output = new List<ContextAnswerQuestion>();
foreach (var question in questions)
{
if (string.IsNullOrWhiteSpace(question.Query) || !seen.Add(question.Query.Trim()))
{
continue;
}
output.Add(question with { Query = question.Query.Trim() });
if (output.Count >= MaxContextAnswerQuestions)
{
break;
}
}
return output;
}
private static string? ResolveContextDomain(
QueryPlan? plan,
IReadOnlyList<EntityCard> cards,
AmbientContext? ambient)
{
if (cards.Count > 0 && !string.IsNullOrWhiteSpace(cards[0].Domain))
{
return cards[0].Domain;
}
var routeDomain = AmbientContextProcessor.ResolveDomainFromRoute(ambient?.CurrentRoute ?? string.Empty);
if (!string.IsNullOrWhiteSpace(routeDomain))
{
return routeDomain;
}
var actionDomain = ambient?.LastAction?.Domain;
if (!string.IsNullOrWhiteSpace(actionDomain))
{
return actionDomain.Trim().ToLowerInvariant();
}
var actionRouteDomain = AmbientContextProcessor.ResolveDomainFromRoute(ambient?.LastAction?.Route ?? string.Empty);
if (!string.IsNullOrWhiteSpace(actionRouteDomain))
{
return actionRouteDomain;
}
return plan?.DomainWeights
.OrderByDescending(static pair => pair.Value)
.ThenBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(static pair => pair.Key)
.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value));
}
private static string DescribeDomain(string domain)
{
return domain switch
{
"findings" => "the findings scope",
"vex" => "the VEX scope",
"policy" => "the policy scope",
"graph" => "the graph scope",
"timeline" => "the timeline scope",
"opsmemory" => "the operations-memory scope",
"knowledge" => "the knowledge scope",
"scanner" => "the scanner scope",
_ => "the current scope"
};
}
private static string FormatDomainList(IEnumerable<string> domains)
{
var labels = domains
.Where(static domain => !string.IsNullOrWhiteSpace(domain))
.Select(static domain => domain switch
{
"findings" => "findings",
"vex" => "VEX",
"policy" => "policy",
"graph" => "graph",
"timeline" => "timeline",
"opsmemory" => "ops memory",
"scanner" => "scanner",
_ => domain
})
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.ToArray();
return labels.Length == 0 ? "knowledge" : string.Join(", ", labels);
}
private static string? GetPrimaryActionRoute(EntityCard card)
{
return card.Actions
.FirstOrDefault(static action => !string.IsNullOrWhiteSpace(action.Route))?
.Route;
}
private const int PreviewContentMaxLength = 2000;
private static EntityCardPreview? BuildPreview(KnowledgeChunkRow row, string domain)
@@ -889,14 +1384,99 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return Math.Clamp(requested.Value, 1, 100);
}
private UnifiedSearchResponse EmptyResponse(string query, int? topK, string mode)
private UnifiedSearchResponse EmptyResponse(string query, int? topK, string mode, AmbientContext? ambient = null)
{
return new UnifiedSearchResponse(
query,
ResolveTopK(topK),
[],
null,
new UnifiedSearchDiagnostics(0, 0, 0, 0, false, mode));
new UnifiedSearchDiagnostics(0, 0, 0, 0, false, mode),
ContextAnswer: BuildEmptyContextAnswer(query, mode, ambient));
}
private ContextAnswer? BuildContextAnswer(
string query,
QueryPlan plan,
AmbientContext? ambient,
IReadOnlyList<EntityCard> cards,
SynthesisResult? synthesis,
IReadOnlyList<SearchSuggestion>? suggestions,
IReadOnlyList<SearchRefinement>? refinements)
{
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
if (cards.Count > 0)
{
var topCard = cards[0];
var citations = BuildContextAnswerCitations(cards, 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),
Citations: citations,
Questions: questions);
}
if (ShouldClarifyQuery(query, plan, ambient, suggestions, refinements))
{
var clarificationScope = ResolveContextDomain(plan, cards, ambient) ?? "current scope";
return new ContextAnswer(
Status: "clarify",
Code: refinements is { Count: > 0 } || suggestions is { Count: > 0 }
? "query_needs_scope_with_recovery"
: "query_needs_scope",
Summary: BuildClarifySummary(query, clarificationScope),
Reason: BuildClarifyReason(plan, ambient),
Evidence: BuildClarifyEvidence(suggestions, refinements, clarificationScope),
Citations: [],
Questions: BuildClarifyingQuestions(query, plan, ambient));
}
var recoveryQuestions = BuildRecoveryQuestions(query, plan, ambient, suggestions, refinements);
return new ContextAnswer(
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),
Citations: [],
Questions: recoveryQuestions);
}
private ContextAnswer? BuildEmptyContextAnswer(string query, string mode, AmbientContext? ambient)
{
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
return mode switch
{
"query_too_long" => new ContextAnswer(
Status: "clarify",
Code: "query_too_long",
Summary: "Shorten the search to a specific component, check, policy, or CVE.",
Reason: "The query exceeded the bounded unified-search length limit, so it cannot be ranked safely.",
Evidence: "No search was executed. Narrow the query before retrying.",
Citations: [],
Questions: BuildClarifyingQuestions(query, plan: null, ambient: ambient)),
"disabled" => new ContextAnswer(
Status: "insufficient",
Code: "search_disabled",
Summary: "Unified search is not enabled for the current environment or tenant.",
Reason: "The request could not be grounded because unified search is disabled before retrieval.",
Evidence: "No search index was queried.",
Citations: [],
Questions: BuildFallbackQuestionsForDisabledSearch(ambient)),
_ => null
};
}
private static string? GetMetadataString(JsonElement metadata, string propertyName)
@@ -1272,6 +1852,10 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
DurationMs: response.Diagnostics.DurationMs,
UsedVector: response.Diagnostics.UsedVector,
DomainWeights: new Dictionary<string, double>(plan.DomainWeights, StringComparer.Ordinal),
TopDomains: topDomains));
TopDomains: topDomains,
AnswerStatus: response.ContextAnswer?.Status,
AnswerCode: response.ContextAnswer?.Code,
HasSuggestions: response.Suggestions is { Count: > 0 },
HasRefinements: response.Refinements is { Count: > 0 }));
}
}

View File

@@ -14,7 +14,11 @@ public sealed record UnifiedSearchTelemetryEvent(
long DurationMs,
bool UsedVector,
IReadOnlyDictionary<string, double> DomainWeights,
IReadOnlyList<string> TopDomains);
IReadOnlyList<string> TopDomains,
string? AnswerStatus = null,
string? AnswerCode = null,
bool HasSuggestions = false,
bool HasRefinements = false);
public interface IUnifiedSearchTelemetrySink
{
@@ -45,13 +49,17 @@ internal sealed class LoggingUnifiedSearchTelemetrySink : IUnifiedSearchTelemetr
: string.Join(",", telemetryEvent.TopDomains.OrderBy(static value => value, StringComparer.Ordinal));
_logger.LogInformation(
"unified_search telemetry tenant={Tenant} query_hash={QueryHash} intent={Intent} results={ResultCount} duration_ms={DurationMs} used_vector={UsedVector} top_domains={TopDomains} weights={Weights}",
"unified_search telemetry tenant={Tenant} query_hash={QueryHash} intent={Intent} results={ResultCount} duration_ms={DurationMs} used_vector={UsedVector} answer_status={AnswerStatus} answer_code={AnswerCode} has_suggestions={HasSuggestions} has_refinements={HasRefinements} top_domains={TopDomains} weights={Weights}",
telemetryEvent.Tenant,
telemetryEvent.QueryHash,
telemetryEvent.Intent,
telemetryEvent.ResultCount,
telemetryEvent.DurationMs,
telemetryEvent.UsedVector,
telemetryEvent.AnswerStatus ?? "-",
telemetryEvent.AnswerCode ?? "-",
telemetryEvent.HasSuggestions,
telemetryEvent.HasRefinements,
topDomains,
weights);
}