Add self-serve search telemetry gap surfacing

This commit is contained in:
master
2026-03-07 17:15:38 +02:00
parent 5fac47f99f
commit 14d7612cc2
13 changed files with 677 additions and 28 deletions

View File

@@ -0,0 +1,20 @@
-- 008_search_self_serve_analytics.sql
-- Adds self-serve telemetry fields to search_events so fallback loops,
-- reformulations, rescue actions, and abandoned fallback sessions can be tracked.
ALTER TABLE advisoryai.search_events
ADD COLUMN IF NOT EXISTS session_id TEXT;
ALTER TABLE advisoryai.search_events
ADD COLUMN IF NOT EXISTS answer_status TEXT;
ALTER TABLE advisoryai.search_events
ADD COLUMN IF NOT EXISTS answer_code TEXT;
CREATE INDEX IF NOT EXISTS idx_search_events_tenant_session
ON advisoryai.search_events (tenant_id, session_id, created_at)
WHERE session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_search_events_answer_status
ON advisoryai.search_events (tenant_id, answer_status, created_at)
WHERE answer_status IS NOT NULL;

View File

@@ -13,7 +13,7 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
| 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-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_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. |

View File

@@ -32,6 +32,19 @@ internal static class SearchAnalyticsPrivacy
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string? HashSessionId(string tenantId, string? sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return null;
}
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
var normalizedSession = sessionId.Trim().ToLowerInvariant();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{normalizedTenant}|session|{normalizedSession}"));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string? RedactFreeform(string? value)
{
_ = value;

View File

@@ -37,11 +37,14 @@ internal sealed class SearchAnalyticsService
await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(@"
INSERT INTO advisoryai.search_events (tenant_id, user_id, event_type, query, entity_key, domain, result_count, position, duration_ms)
VALUES (@tenant_id, @user_id, @event_type, @query, @entity_key, @domain, @result_count, @position, @duration_ms)", conn);
INSERT INTO advisoryai.search_events
(tenant_id, user_id, session_id, event_type, query, entity_key, domain, result_count, position, duration_ms, answer_status, answer_code)
VALUES
(@tenant_id, @user_id, @session_id, @event_type, @query, @entity_key, @domain, @result_count, @position, @duration_ms, @answer_status, @answer_code)", conn);
cmd.Parameters.AddWithValue("tenant_id", persistedEvent.TenantId);
cmd.Parameters.AddWithValue("user_id", (object?)persistedEvent.UserId ?? DBNull.Value);
cmd.Parameters.AddWithValue("session_id", (object?)persistedEvent.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("event_type", persistedEvent.EventType);
cmd.Parameters.AddWithValue("query", persistedEvent.Query);
cmd.Parameters.AddWithValue("entity_key", (object?)persistedEvent.EntityKey ?? DBNull.Value);
@@ -49,6 +52,8 @@ internal sealed class SearchAnalyticsService
cmd.Parameters.AddWithValue("result_count", (object?)persistedEvent.ResultCount ?? DBNull.Value);
cmd.Parameters.AddWithValue("position", (object?)persistedEvent.Position ?? DBNull.Value);
cmd.Parameters.AddWithValue("duration_ms", (object?)persistedEvent.DurationMs ?? DBNull.Value);
cmd.Parameters.AddWithValue("answer_status", (object?)persistedEvent.AnswerStatus ?? DBNull.Value);
cmd.Parameters.AddWithValue("answer_code", (object?)persistedEvent.AnswerCode ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
RecordFallbackEvent(persistedEvent, recordedAt);
@@ -88,11 +93,14 @@ internal sealed class SearchAnalyticsService
var persistedEvent = SanitizeEvent(evt);
await using var cmd = new NpgsqlCommand(@"
INSERT INTO advisoryai.search_events (tenant_id, user_id, event_type, query, entity_key, domain, result_count, position, duration_ms)
VALUES (@tenant_id, @user_id, @event_type, @query, @entity_key, @domain, @result_count, @position, @duration_ms)", conn);
INSERT INTO advisoryai.search_events
(tenant_id, user_id, session_id, event_type, query, entity_key, domain, result_count, position, duration_ms, answer_status, answer_code)
VALUES
(@tenant_id, @user_id, @session_id, @event_type, @query, @entity_key, @domain, @result_count, @position, @duration_ms, @answer_status, @answer_code)", conn);
cmd.Parameters.AddWithValue("tenant_id", persistedEvent.TenantId);
cmd.Parameters.AddWithValue("user_id", (object?)persistedEvent.UserId ?? DBNull.Value);
cmd.Parameters.AddWithValue("session_id", (object?)persistedEvent.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("event_type", persistedEvent.EventType);
cmd.Parameters.AddWithValue("query", persistedEvent.Query);
cmd.Parameters.AddWithValue("entity_key", (object?)persistedEvent.EntityKey ?? DBNull.Value);
@@ -100,6 +108,8 @@ internal sealed class SearchAnalyticsService
cmd.Parameters.AddWithValue("result_count", (object?)persistedEvent.ResultCount ?? DBNull.Value);
cmd.Parameters.AddWithValue("position", (object?)persistedEvent.Position ?? DBNull.Value);
cmd.Parameters.AddWithValue("duration_ms", (object?)persistedEvent.DurationMs ?? DBNull.Value);
cmd.Parameters.AddWithValue("answer_status", (object?)persistedEvent.AnswerStatus ?? DBNull.Value);
cmd.Parameters.AddWithValue("answer_code", (object?)persistedEvent.AnswerCode ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
RecordFallbackEvent(persistedEvent, recordedAt);
@@ -637,8 +647,16 @@ internal sealed class SearchAnalyticsService
{
return evt with
{
EventType = evt.EventType.Trim().ToLowerInvariant(),
Query = SearchAnalyticsPrivacy.HashQuery(evt.Query),
UserId = SearchAnalyticsPrivacy.HashUserId(evt.TenantId, evt.UserId)
UserId = SearchAnalyticsPrivacy.HashUserId(evt.TenantId, evt.UserId),
SessionId = SearchAnalyticsPrivacy.HashSessionId(evt.TenantId, evt.SessionId),
AnswerStatus = string.IsNullOrWhiteSpace(evt.AnswerStatus)
? null
: evt.AnswerStatus.Trim().ToLowerInvariant(),
AnswerCode = string.IsNullOrWhiteSpace(evt.AnswerCode)
? null
: evt.AnswerCode.Trim().ToLowerInvariant()
};
}
@@ -667,11 +685,14 @@ internal record SearchAnalyticsEvent(
string EventType,
string Query,
string? UserId = null,
string? SessionId = null,
string? EntityKey = null,
string? Domain = null,
int? ResultCount = null,
int? Position = null,
int? DurationMs = null);
int? DurationMs = null,
string? AnswerStatus = null,
string? AnswerCode = null);
internal record SearchHistoryEntry(
string HistoryId,

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
@@ -6,7 +7,8 @@ using StellaOps.AdvisoryAI.KnowledgeSearch;
namespace StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
/// <summary>
/// Monitors search quality by analysing feedback data and zero-result queries.
/// Monitors search quality by analysing feedback data, zero-result queries,
/// and self-serve fallback telemetry.
/// Provides CRUD for search_quality_alerts and search_feedback tables.
/// Sprint: SPRINT_20260224_110 (G10-001, G10-002)
/// </summary>
@@ -17,6 +19,8 @@ internal sealed class SearchQualityMonitor
private const int DefaultAlertWindowDays = 7;
private const int ZeroResultAlertThreshold = 3;
private const int NegativeFeedbackAlertThreshold = 3;
private const int FallbackLoopAlertThreshold = 3;
private const int AbandonedFallbackAlertThreshold = 3;
private readonly KnowledgeSearchOptions _options;
private readonly ILogger<SearchQualityMonitor> _logger;
@@ -175,6 +179,32 @@ internal sealed class SearchQualityMonitor
candidate.LastSeen,
ct).ConfigureAwait(false);
}
var selfServeSignals = await LoadSelfServeSignalEventsAsync(tenantId, window, ct).ConfigureAwait(false);
foreach (var candidate in BuildFallbackLoopCandidates(selfServeSignals))
{
await UpsertAlertAsync(
tenantId,
alertType: "fallback_loop",
candidate.Query,
candidate.OccurrenceCount,
candidate.FirstSeen,
candidate.LastSeen,
ct).ConfigureAwait(false);
}
foreach (var candidate in BuildAbandonedFallbackCandidates(selfServeSignals))
{
await UpsertAlertAsync(
tenantId,
alertType: "abandoned_fallback",
candidate.Query,
candidate.OccurrenceCount,
candidate.FirstSeen,
candidate.LastSeen,
ct).ConfigureAwait(false);
}
}
public async Task<IReadOnlyList<SearchQualityAlertEntry>> GetAlertsAsync(
@@ -423,6 +453,9 @@ internal sealed class SearchQualityMonitor
}
await feedbackReader.CloseAsync().ConfigureAwait(false);
var selfServeSignals = await LoadSelfServeSignalEventsAsync(conn, tenantId, days, ct).ConfigureAwait(false);
ApplySelfServeMetrics(metrics, selfServeSignals);
metrics.LowQualityResults = await LoadLowQualityResultsAsync(conn, tenantId, days, ct).ConfigureAwait(false);
metrics.TopQueries = await LoadTopQueriesAsync(conn, tenantId, days, ct).ConfigureAwait(false);
metrics.Trend = await LoadTrendPointsAsync(conn, tenantId, ct).ConfigureAwait(false);
@@ -473,7 +506,8 @@ internal sealed class SearchQualityMonitor
? 0d
: (double)helpfulCount / feedbackSignals.Length * 100d;
return new SearchQualityMetricsEntry
var selfServeSignals = BuildFallbackSelfServeSignalEvents(tenantId, window);
var metrics = new SearchQualityMetricsEntry
{
TotalSearches = totalSearches,
ZeroResultRate = totalSearches == 0 ? 0d : Math.Round((double)zeroResults / totalSearches * 100d, 1),
@@ -484,6 +518,10 @@ internal sealed class SearchQualityMonitor
TopQueries = BuildFallbackTopQueries(tenantId, window),
Trend = BuildFallbackTrendPoints(tenantId),
};
ApplySelfServeMetrics(metrics, selfServeSignals);
return metrics;
}
private async Task<IReadOnlyList<SearchQualityLowQualityRow>> LoadLowQualityResultsAsync(
@@ -758,6 +796,232 @@ internal sealed class SearchQualityMonitor
return points;
}
private async Task<IReadOnlyList<SelfServeSignalEvent>> LoadSelfServeSignalEventsAsync(
string tenantId,
TimeSpan window,
CancellationToken ct)
{
var days = Math.Max(1, (int)Math.Ceiling(window.TotalDays));
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
{
try
{
await using var conn = new NpgsqlConnection(_options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return await LoadSelfServeSignalEventsAsync(conn, tenantId, days, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load self-serve signal events from database.");
}
}
return BuildFallbackSelfServeSignalEvents(tenantId, window);
}
private static async Task<IReadOnlyList<SelfServeSignalEvent>> LoadSelfServeSignalEventsAsync(
NpgsqlConnection conn,
string tenantId,
int days,
CancellationToken ct)
{
var events = new List<SelfServeSignalEvent>();
await using var cmd = new NpgsqlCommand(@"
SELECT
COALESCE(session_id, ''),
event_type,
query,
answer_status,
created_at,
event_id::text
FROM advisoryai.search_events
WHERE tenant_id = @tenant_id
AND created_at > now() - make_interval(days => @days)
AND event_type IN ('answer_frame', 'reformulation', 'rescue_action', 'click')
ORDER BY created_at ASC, event_id ASC", conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("days", days);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
events.Add(new SelfServeSignalEvent(
SessionId: reader.IsDBNull(0) ? null : NullIfEmpty(reader.GetString(0)),
EventType: reader.GetString(1),
Query: reader.GetString(2),
AnswerStatus: reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt: new DateTimeOffset(reader.GetDateTime(4), TimeSpan.Zero),
OrderKey: reader.GetString(5)));
}
return events;
}
private IReadOnlyList<SelfServeSignalEvent> BuildFallbackSelfServeSignalEvents(string tenantId, TimeSpan window)
{
return _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
.Where(static item =>
item.Event.EventType.Equals("answer_frame", StringComparison.OrdinalIgnoreCase) ||
item.Event.EventType.Equals("reformulation", StringComparison.OrdinalIgnoreCase) ||
item.Event.EventType.Equals("rescue_action", StringComparison.OrdinalIgnoreCase) ||
item.Event.EventType.Equals("click", StringComparison.OrdinalIgnoreCase))
.Select((item, index) => new SelfServeSignalEvent(
SessionId: item.Event.SessionId,
EventType: item.Event.EventType,
Query: item.Event.Query,
AnswerStatus: item.Event.AnswerStatus,
CreatedAt: item.RecordedAt,
OrderKey: index.ToString("D8", CultureInfo.InvariantCulture)))
.ToArray();
}
private static void ApplySelfServeMetrics(SearchQualityMetricsEntry metrics, IReadOnlyList<SelfServeSignalEvent> signals)
{
var totalAnswerFrames = signals.Count(static evt =>
evt.EventType.Equals("answer_frame", StringComparison.OrdinalIgnoreCase));
var fallbackAnswers = signals.Count(IsFallbackAnswerEvent);
var clarifyAnswers = signals.Count(static evt =>
evt.EventType.Equals("answer_frame", StringComparison.OrdinalIgnoreCase) &&
string.Equals(evt.AnswerStatus, "clarify", StringComparison.OrdinalIgnoreCase));
var insufficientAnswers = signals.Count(static evt =>
evt.EventType.Equals("answer_frame", StringComparison.OrdinalIgnoreCase) &&
string.Equals(evt.AnswerStatus, "insufficient", StringComparison.OrdinalIgnoreCase));
metrics.FallbackAnswerRate = totalAnswerFrames == 0
? 0d
: Math.Round((double)fallbackAnswers / totalAnswerFrames * 100d, 1);
metrics.ClarifyRate = totalAnswerFrames == 0
? 0d
: Math.Round((double)clarifyAnswers / totalAnswerFrames * 100d, 1);
metrics.InsufficientRate = totalAnswerFrames == 0
? 0d
: Math.Round((double)insufficientAnswers / totalAnswerFrames * 100d, 1);
metrics.ReformulationCount = signals.Count(static evt =>
evt.EventType.Equals("reformulation", StringComparison.OrdinalIgnoreCase));
metrics.RescueActionCount = signals.Count(static evt =>
evt.EventType.Equals("rescue_action", StringComparison.OrdinalIgnoreCase));
metrics.AbandonedFallbackCount = GetAbandonedFallbackSessions(signals).Count;
}
private static IReadOnlyList<AlertCandidate> BuildFallbackLoopCandidates(IReadOnlyList<SelfServeSignalEvent> events)
{
var rawCandidates = new List<AlertCandidate>();
foreach (var group in events
.Where(static evt => !string.IsNullOrWhiteSpace(evt.SessionId))
.GroupBy(static evt => evt.SessionId!, StringComparer.Ordinal))
{
var ordered = OrderSessionEvents(group);
var fallbackFrames = ordered.Where(IsFallbackAnswerEvent).ToArray();
if (fallbackFrames.Length < FallbackLoopAlertThreshold)
{
continue;
}
var first = fallbackFrames[0];
var last = fallbackFrames[^1];
rawCandidates.Add(new AlertCandidate(last.Query, fallbackFrames.Length, first.CreatedAt, last.CreatedAt));
}
return rawCandidates
.GroupBy(static candidate => candidate.Query, StringComparer.OrdinalIgnoreCase)
.Select(group => new AlertCandidate(
group.Key,
group.Sum(static candidate => candidate.OccurrenceCount),
group.Min(static candidate => candidate.FirstSeen),
group.Max(static candidate => candidate.LastSeen)))
.OrderByDescending(static candidate => candidate.OccurrenceCount)
.ToArray();
}
private static IReadOnlyList<AlertCandidate> BuildAbandonedFallbackCandidates(IReadOnlyList<SelfServeSignalEvent> events)
{
return GetAbandonedFallbackSessions(events)
.GroupBy(static candidate => candidate.Query, StringComparer.OrdinalIgnoreCase)
.Select(group => new AlertCandidate(
group.Key,
group.Sum(static candidate => candidate.OccurrenceCount),
group.Min(static candidate => candidate.FirstSeen),
group.Max(static candidate => candidate.LastSeen)))
.Where(static candidate => candidate.OccurrenceCount >= AbandonedFallbackAlertThreshold)
.OrderByDescending(static candidate => candidate.OccurrenceCount)
.ToArray();
}
private static IReadOnlyList<AlertCandidate> GetAbandonedFallbackSessions(IReadOnlyList<SelfServeSignalEvent> events)
{
var candidates = new List<AlertCandidate>();
foreach (var group in events
.Where(static evt => !string.IsNullOrWhiteSpace(evt.SessionId))
.GroupBy(static evt => evt.SessionId!, StringComparer.Ordinal))
{
var ordered = OrderSessionEvents(group);
var lastFallbackIndex = -1;
for (var index = ordered.Length - 1; index >= 0; index--)
{
if (IsFallbackAnswerEvent(ordered[index]))
{
lastFallbackIndex = index;
break;
}
}
if (lastFallbackIndex < 0)
{
continue;
}
var lastFallback = ordered[lastFallbackIndex];
var recovered = false;
for (var index = lastFallbackIndex + 1; index < ordered.Length; index++)
{
if (IsRecoveryEvent(ordered[index]))
{
recovered = true;
break;
}
}
if (recovered)
{
continue;
}
candidates.Add(new AlertCandidate(lastFallback.Query, 1, lastFallback.CreatedAt, lastFallback.CreatedAt));
}
return candidates;
}
private static bool IsFallbackAnswerEvent(SelfServeSignalEvent evt)
{
return evt.EventType.Equals("answer_frame", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(evt.AnswerStatus, "clarify", StringComparison.OrdinalIgnoreCase)
|| string.Equals(evt.AnswerStatus, "insufficient", StringComparison.OrdinalIgnoreCase));
}
private static bool IsRecoveryEvent(SelfServeSignalEvent evt)
{
return evt.EventType.Equals("click", StringComparison.OrdinalIgnoreCase)
|| evt.EventType.Equals("reformulation", StringComparison.OrdinalIgnoreCase)
|| evt.EventType.Equals("rescue_action", StringComparison.OrdinalIgnoreCase)
|| (evt.EventType.Equals("answer_frame", StringComparison.OrdinalIgnoreCase)
&& string.Equals(evt.AnswerStatus, "grounded", StringComparison.OrdinalIgnoreCase));
}
private static SelfServeSignalEvent[] OrderSessionEvents(IEnumerable<SelfServeSignalEvent> events)
{
return events
.OrderBy(static evt => evt.CreatedAt)
.ThenBy(static evt => evt.OrderKey, StringComparer.Ordinal)
.ToArray();
}
private static string? NullIfEmpty(string value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private async Task<IReadOnlyList<AlertCandidate>> LoadZeroResultCandidatesAsync(
string tenantId,
TimeSpan window,
@@ -1170,12 +1434,26 @@ internal sealed class SearchQualityMetricsEntry
public double ZeroResultRate { get; set; }
public double AvgResultCount { get; set; }
public double FeedbackScore { get; set; }
public double FallbackAnswerRate { get; set; }
public double ClarifyRate { get; set; }
public double InsufficientRate { get; set; }
public int ReformulationCount { get; set; }
public int RescueActionCount { get; set; }
public int AbandonedFallbackCount { get; set; }
public string Period { get; set; } = "7d";
public IReadOnlyList<SearchQualityLowQualityRow> LowQualityResults { get; set; } = [];
public IReadOnlyList<SearchQualityTopQueryRow> TopQueries { get; set; } = [];
public IReadOnlyList<SearchQualityTrendPoint> Trend { get; set; } = [];
}
internal sealed record SelfServeSignalEvent(
string? SessionId,
string EventType,
string Query,
string? AnswerStatus,
DateTimeOffset CreatedAt,
string OrderKey);
internal sealed class SearchQualityLowQualityRow
{
public string EntityKey { get; set; } = string.Empty;

View File

@@ -107,18 +107,23 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
return EmptyResponse(string.Empty, request.K, "empty");
}
if (query.Length > _unifiedOptions.MaxQueryLength)
{
return EmptyResponse(query, request.K, "query_too_long", request.Ambient);
}
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);
return earlyResponse;
}
var tenantFlags = ResolveTenantFeatureFlags(tenantId);
if (!_options.Enabled || !IsSearchEnabledForTenant(tenantFlags) || string.IsNullOrWhiteSpace(_options.ConnectionString))
{
return EmptyResponse(query, request.K, "disabled", request.Ambient);
var earlyResponse = EmptyResponse(query, request.K, "disabled", request.Ambient);
await RecordAnswerFrameAnalyticsAsync(tenantId, userId, request, plan: null, earlyResponse, cancellationToken).ConfigureAwait(false);
return earlyResponse;
}
if (request.Ambient?.ResetSession == true &&
@@ -321,6 +326,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
refinements,
contextAnswer);
await RecordAnswerFrameAnalyticsAsync(tenantId, userId, request, plan, response, cancellationToken).ConfigureAwait(false);
EmitTelemetry(plan, response, tenantId);
return response;
}
@@ -1858,4 +1864,40 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
HasSuggestions: response.Suggestions is { Count: > 0 },
HasRefinements: response.Refinements is { Count: > 0 }));
}
private async Task RecordAnswerFrameAnalyticsAsync(
string tenantId,
string userId,
UnifiedSearchRequest request,
QueryPlan? plan,
UnifiedSearchResponse response,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(response.Query))
{
return;
}
var contextAnswer = response.ContextAnswer;
if (contextAnswer is null)
{
return;
}
var domain = ResolveContextDomain(plan, response.Cards, request.Ambient)
?? response.Cards.FirstOrDefault()?.Domain
?? request.Filters?.Domains?.FirstOrDefault();
await _analyticsService.RecordEventAsync(new SearchAnalyticsEvent(
TenantId: tenantId,
EventType: "answer_frame",
Query: response.Query,
UserId: userId,
SessionId: request.Ambient?.SessionId,
Domain: domain,
ResultCount: response.Cards.Count,
DurationMs: (int)Math.Min(int.MaxValue, Math.Max(0, response.Diagnostics.DurationMs)),
AnswerStatus: contextAnswer.Status,
AnswerCode: contextAnswer.Code), cancellationToken).ConfigureAwait(false);
}
}