documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF
This commit is contained in:
@@ -200,7 +200,7 @@ public sealed class ChatResponseStreamer
|
||||
// Pattern: [type:path]
|
||||
var matches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs):(?<path>[^\]]+)\]");
|
||||
@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs|finding|scan|policy):(?<path>[^\]]+)\]");
|
||||
|
||||
for (int i = existingCount; i < matches.Count; i++)
|
||||
{
|
||||
|
||||
@@ -95,6 +95,17 @@ public sealed class KnowledgeSearchOptions
|
||||
/// </summary>
|
||||
public bool RoleBasedBiasEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enables periodic quality-alert refresh from analytics and feedback signals.
|
||||
/// </summary>
|
||||
public bool SearchQualityMonitorEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Interval in seconds for quality-monitor refresh.
|
||||
/// </summary>
|
||||
[Range(30, 86400)]
|
||||
public int SearchQualityMonitorIntervalSeconds { get; set; } = 300;
|
||||
|
||||
// ── Live adapter settings (Sprint 103 / G2) ──
|
||||
|
||||
/// <summary>Base URL for the Scanner microservice (e.g. "http://scanner:8080").</summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"checkCode": "check.core.disk.space",
|
||||
"title": "Speicherplatzverfügbarkeit",
|
||||
@@ -168,3 +168,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"checkCode": "check.core.disk.space",
|
||||
"title": "Disponibilité de l'espace disque",
|
||||
|
||||
@@ -9,6 +9,9 @@ internal sealed class SearchAnalyticsService
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<SearchAnalyticsService> _logger;
|
||||
private readonly object _fallbackLock = new();
|
||||
private readonly List<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> _fallbackEvents = [];
|
||||
private readonly Dictionary<(string TenantId, string UserId, string Query), SearchHistoryEntry> _fallbackHistory = new();
|
||||
|
||||
public SearchAnalyticsService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
@@ -20,7 +23,12 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task RecordEventAsync(SearchAnalyticsEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -42,16 +50,32 @@ internal sealed class SearchAnalyticsService
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)evt.DurationMs ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to record search analytics event");
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecordEventsAsync(IReadOnlyList<SearchAnalyticsEvent> events, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString) || events.Count == 0) return;
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
foreach (var evt in events)
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -75,18 +99,27 @@ internal sealed class SearchAnalyticsService
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)evt.DurationMs ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to record search analytics events batch ({Count} events)", events.Count);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, int>> GetPopularityMapAsync(string tenantId, int days = 30, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return BuildFallbackPopularityMap(tenantId, days);
|
||||
}
|
||||
|
||||
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return map;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -116,6 +149,7 @@ internal sealed class SearchAnalyticsService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load popularity map");
|
||||
return BuildFallbackPopularityMap(tenantId, days);
|
||||
}
|
||||
|
||||
return map;
|
||||
@@ -123,7 +157,12 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task RecordHistoryAsync(string tenantId, string userId, string query, int resultCount, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -156,17 +195,23 @@ internal sealed class SearchAnalyticsService
|
||||
trimCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
trimCmd.Parameters.AddWithValue("user_id", userId);
|
||||
await trimCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to record search history");
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SearchHistoryEntry>> GetHistoryAsync(string tenantId, string userId, int limit = 50, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return GetFallbackHistory(tenantId, userId, limit);
|
||||
}
|
||||
|
||||
var entries = new List<SearchHistoryEntry>();
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return entries;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -197,6 +242,7 @@ internal sealed class SearchAnalyticsService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load search history");
|
||||
return GetFallbackHistory(tenantId, userId, limit);
|
||||
}
|
||||
|
||||
return entries;
|
||||
@@ -204,7 +250,11 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task ClearHistoryAsync(string tenantId, string userId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -219,10 +269,12 @@ internal sealed class SearchAnalyticsService
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to clear search history");
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,9 +288,14 @@ internal sealed class SearchAnalyticsService
|
||||
string tenantId, string query, int limit = 3, CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString) || string.IsNullOrWhiteSpace(query))
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return results;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return FindFallbackSimilarQueries(tenantId, query, limit);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
@@ -268,6 +325,7 @@ internal sealed class SearchAnalyticsService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to find similar successful queries for '{Query}'", query);
|
||||
return FindFallbackSimilarQueries(tenantId, query, limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -275,7 +333,11 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task DeleteHistoryEntryAsync(string tenantId, string userId, string historyId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(historyId, out _)) return;
|
||||
|
||||
@@ -293,12 +355,204 @@ internal sealed class SearchAnalyticsService
|
||||
cmd.Parameters.AddWithValue("history_id", Guid.Parse(historyId));
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete search history entry");
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
}
|
||||
}
|
||||
|
||||
internal IReadOnlyList<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> GetFallbackEventsSnapshot(
|
||||
string tenantId,
|
||||
TimeSpan window)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - window;
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackEvents
|
||||
.Where(item => item.Event.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(item => item.RecordedAt >= cutoff)
|
||||
.OrderBy(item => item.RecordedAt)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal IReadOnlySet<string> GetKnownFallbackTenants()
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackEvents
|
||||
.Select(item => item.Event.TenantId)
|
||||
.Where(static t => !string.IsNullOrWhiteSpace(t))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, int> BuildFallbackPopularityMap(string tenantId, int days)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - TimeSpan.FromDays(Math.Max(1, days));
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackEvents
|
||||
.Where(item => item.RecordedAt >= cutoff)
|
||||
.Select(item => item.Event)
|
||||
.Where(evt => evt.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(evt => evt.EventType.Equals("click", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(evt => !string.IsNullOrWhiteSpace(evt.EntityKey))
|
||||
.GroupBy(evt => evt.EntityKey!, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<SearchHistoryEntry> GetFallbackHistory(string tenantId, string userId, int limit)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackHistory
|
||||
.Where(item =>
|
||||
item.Key.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
item.Key.UserId.Equals(userId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(item => item.Value)
|
||||
.OrderByDescending(entry => entry.SearchedAt)
|
||||
.Take(Math.Max(1, limit))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFallbackEvent(SearchAnalyticsEvent evt, DateTimeOffset recordedAt)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
_fallbackEvents.Add((evt, recordedAt));
|
||||
if (_fallbackEvents.Count > 20_000)
|
||||
{
|
||||
_fallbackEvents.RemoveRange(0, _fallbackEvents.Count - 20_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFallbackHistory(string tenantId, string userId, string query, int resultCount, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedQuery = query.Trim();
|
||||
(string TenantId, string UserId, string Query) key = (tenantId, userId, normalizedQuery);
|
||||
var historyId = BuildFallbackHistoryId(tenantId, userId, normalizedQuery);
|
||||
var entry = new SearchHistoryEntry(historyId, normalizedQuery, resultCount, recordedAt.UtcDateTime);
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
_fallbackHistory[key] = entry;
|
||||
|
||||
var overflow = _fallbackHistory.Keys
|
||||
.Where(k => k.TenantId == key.TenantId && k.UserId == key.UserId)
|
||||
.Select(k => (Key: k, Entry: _fallbackHistory[k]))
|
||||
.OrderByDescending(item => item.Entry.SearchedAt)
|
||||
.Skip(50)
|
||||
.Select(item => item.Key)
|
||||
.ToArray();
|
||||
|
||||
foreach (var removeKey in overflow)
|
||||
{
|
||||
_fallbackHistory.Remove(removeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearFallbackHistory(string tenantId, string userId)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var keys = _fallbackHistory.Keys
|
||||
.Where(key => key.TenantId == tenantId && key.UserId == userId)
|
||||
.ToArray();
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
_fallbackHistory.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteFallbackHistoryEntry(string tenantId, string userId, string historyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(historyId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var hit = _fallbackHistory.Keys
|
||||
.FirstOrDefault(key =>
|
||||
key.TenantId == tenantId &&
|
||||
key.UserId == userId &&
|
||||
BuildFallbackHistoryId(key.TenantId, key.UserId, key.Query).Equals(historyId, StringComparison.Ordinal));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hit.TenantId))
|
||||
{
|
||||
_fallbackHistory.Remove(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> FindFallbackSimilarQueries(string tenantId, string query, int limit)
|
||||
{
|
||||
var normalized = query.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackHistory
|
||||
.Where(item => item.Key.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(item => item.Value)
|
||||
.Where(entry => !string.Equals(entry.Query, normalized, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(entry => (entry.Query, Score: ComputeTokenSimilarity(entry.Query, normalized)))
|
||||
.Where(item => item.Score > 0.2d)
|
||||
.OrderByDescending(item => item.Score)
|
||||
.ThenBy(item => item.Query, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(Math.Max(1, limit))
|
||||
.Select(item => item.Query)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildFallbackHistoryId(string tenantId, string userId, string query)
|
||||
{
|
||||
var normalizedQuery = query.Trim().ToLowerInvariant();
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{tenantId}|{userId}|{normalizedQuery}"));
|
||||
var guidBytes = hash[..16];
|
||||
return new Guid(guidBytes).ToString("D");
|
||||
}
|
||||
|
||||
private static double ComputeTokenSimilarity(string a, string b)
|
||||
{
|
||||
var left = a.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static token => token.ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var right = b.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static token => token.ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (left.Count == 0 || right.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var intersection = left.Intersect(right, StringComparer.Ordinal).Count();
|
||||
var union = left.Union(right, StringComparer.Ordinal).Count();
|
||||
return union == 0 ? 0d : (double)intersection / union;
|
||||
}
|
||||
}
|
||||
|
||||
internal record SearchAnalyticsEvent(
|
||||
|
||||
@@ -14,23 +14,38 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
private static readonly HashSet<string> AllowedSignals = new(StringComparer.Ordinal) { "helpful", "not_helpful" };
|
||||
private static readonly HashSet<string> AllowedAlertStatuses = new(StringComparer.Ordinal) { "acknowledged", "resolved" };
|
||||
private const int DefaultAlertWindowDays = 7;
|
||||
private const int ZeroResultAlertThreshold = 3;
|
||||
private const int NegativeFeedbackAlertThreshold = 3;
|
||||
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<SearchQualityMonitor> _logger;
|
||||
private readonly SearchAnalyticsService _analyticsService;
|
||||
private readonly object _fallbackLock = new();
|
||||
private readonly List<(SearchFeedbackEntry Entry, DateTimeOffset CreatedAt)> _fallbackFeedback = [];
|
||||
private readonly List<SearchQualityAlertEntry> _fallbackAlerts = [];
|
||||
|
||||
public SearchQualityMonitor(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
ILogger<SearchQualityMonitor> logger)
|
||||
ILogger<SearchQualityMonitor> logger,
|
||||
SearchAnalyticsService? analyticsService = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_analyticsService = analyticsService ??
|
||||
new SearchAnalyticsService(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<SearchAnalyticsService>.Instance);
|
||||
}
|
||||
|
||||
// ----- Feedback CRUD -----
|
||||
|
||||
public async Task StoreFeedbackAsync(SearchFeedbackEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -53,15 +68,114 @@ internal sealed class SearchQualityMonitor
|
||||
cmd.Parameters.AddWithValue("comment", (object?)entry.Comment ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to store search feedback");
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Quality Alerts -----
|
||||
|
||||
public async Task<int> RefreshAlertsForKnownTenantsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var tenants = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var tenant in _analyticsService.GetKnownFallbackTenants())
|
||||
{
|
||||
tenants.Add(tenant);
|
||||
}
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
foreach (var item in _fallbackFeedback)
|
||||
{
|
||||
tenants.Add(item.Entry.TenantId);
|
||||
}
|
||||
|
||||
foreach (var alert in _fallbackAlerts)
|
||||
{
|
||||
tenants.Add(alert.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT DISTINCT tenant_id FROM advisoryai.search_events
|
||||
UNION
|
||||
SELECT DISTINCT tenant_id FROM advisoryai.search_feedback
|
||||
UNION
|
||||
SELECT DISTINCT tenant_id FROM advisoryai.search_quality_alerts", conn);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
if (!reader.IsDBNull(0))
|
||||
{
|
||||
tenants.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to enumerate tenants for quality alert refresh.");
|
||||
}
|
||||
}
|
||||
|
||||
var refreshed = 0;
|
||||
foreach (var tenantId in tenants)
|
||||
{
|
||||
await RefreshAlertsAsync(tenantId, ct).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
}
|
||||
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
public async Task RefreshAlertsAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var window = TimeSpan.FromDays(DefaultAlertWindowDays);
|
||||
|
||||
var zeroResultCandidates = await LoadZeroResultCandidatesAsync(tenantId, window, ct).ConfigureAwait(false);
|
||||
foreach (var candidate in zeroResultCandidates)
|
||||
{
|
||||
await UpsertAlertAsync(
|
||||
tenantId,
|
||||
alertType: "zero_result",
|
||||
candidate.Query,
|
||||
candidate.OccurrenceCount,
|
||||
candidate.FirstSeen,
|
||||
candidate.LastSeen,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var negativeFeedbackCandidates = await LoadNegativeFeedbackCandidatesAsync(tenantId, window, ct).ConfigureAwait(false);
|
||||
foreach (var candidate in negativeFeedbackCandidates)
|
||||
{
|
||||
await UpsertAlertAsync(
|
||||
tenantId,
|
||||
alertType: "high_negative_feedback",
|
||||
candidate.Query,
|
||||
candidate.OccurrenceCount,
|
||||
candidate.FirstSeen,
|
||||
candidate.LastSeen,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SearchQualityAlertEntry>> GetAlertsAsync(
|
||||
string tenantId,
|
||||
string? status = null,
|
||||
@@ -70,7 +184,22 @@ internal sealed class SearchQualityMonitor
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var alerts = new List<SearchQualityAlertEntry>();
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return alerts;
|
||||
await RefreshAlertsAsync(tenantId, ct).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackAlerts
|
||||
.Where(entry => entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(entry => string.IsNullOrWhiteSpace(status) || entry.Status.Equals(status, StringComparison.Ordinal))
|
||||
.Where(entry => string.IsNullOrWhiteSpace(alertType) || entry.AlertType.Equals(alertType, StringComparison.Ordinal))
|
||||
.OrderByDescending(entry => entry.OccurrenceCount)
|
||||
.ThenByDescending(entry => entry.LastSeen)
|
||||
.Take(Math.Max(1, limit))
|
||||
.Select(CloneAlertEntry)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -132,10 +261,49 @@ internal sealed class SearchQualityMonitor
|
||||
string? resolution,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return null;
|
||||
if (string.IsNullOrWhiteSpace(tenantId)) return null;
|
||||
if (!Guid.TryParse(alertId, out var parsedAlertId)) return null;
|
||||
if (!AllowedAlertStatuses.Contains(status)) return null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var existing = _fallbackAlerts.FirstOrDefault(entry =>
|
||||
entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.AlertId.Equals(alertId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var updated = new SearchQualityAlertEntry
|
||||
{
|
||||
AlertId = existing.AlertId,
|
||||
TenantId = existing.TenantId,
|
||||
AlertType = existing.AlertType,
|
||||
Query = existing.Query,
|
||||
OccurrenceCount = existing.OccurrenceCount,
|
||||
FirstSeen = existing.FirstSeen,
|
||||
LastSeen = existing.LastSeen,
|
||||
Status = status,
|
||||
Resolution = resolution,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
};
|
||||
|
||||
var index = _fallbackAlerts.FindIndex(entry =>
|
||||
entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.AlertId.Equals(alertId, StringComparison.OrdinalIgnoreCase));
|
||||
if (index >= 0)
|
||||
{
|
||||
_fallbackAlerts[index] = updated;
|
||||
}
|
||||
|
||||
return CloneAlertEntry(updated);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
@@ -174,6 +342,13 @@ internal sealed class SearchQualityMonitor
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update search quality alert {AlertId}", alertId);
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var existing = _fallbackAlerts.FirstOrDefault(entry =>
|
||||
entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.AlertId.Equals(alertId, StringComparison.OrdinalIgnoreCase));
|
||||
return existing is null ? null : CloneAlertEntry(existing);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -187,14 +362,13 @@ internal sealed class SearchQualityMonitor
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var metrics = new SearchQualityMetricsEntry { Period = period };
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return metrics;
|
||||
|
||||
var days = period switch
|
||||
var days = ResolvePeriodDays(period);
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
"24h" => 1,
|
||||
"30d" => 30,
|
||||
_ => 7,
|
||||
};
|
||||
return BuildFallbackMetrics(tenantId, days, period);
|
||||
}
|
||||
|
||||
metrics.Period = period;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -204,12 +378,18 @@ internal sealed class SearchQualityMonitor
|
||||
// Total searches and zero-result rate from search_events
|
||||
await using var searchCmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
COUNT(*) AS total_searches,
|
||||
COALESCE(AVG(CASE WHEN result_count = 0 THEN 1.0 ELSE 0.0 END), 0) AS zero_result_rate,
|
||||
COALESCE(AVG(result_count), 0) AS avg_result_count
|
||||
COUNT(*) FILTER (WHERE event_type IN ('query', 'zero_result')) AS total_searches,
|
||||
COALESCE(
|
||||
COUNT(*) FILTER (WHERE event_type = 'zero_result')::double precision /
|
||||
NULLIF(COUNT(*) FILTER (WHERE event_type IN ('query', 'zero_result')), 0),
|
||||
0
|
||||
) AS zero_result_rate,
|
||||
COALESCE(
|
||||
AVG(result_count) FILTER (WHERE event_type IN ('query', 'zero_result') AND result_count IS NOT NULL),
|
||||
0
|
||||
) AS avg_result_count
|
||||
FROM advisoryai.search_events
|
||||
WHERE event_type = 'search'
|
||||
AND tenant_id = @tenant_id
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND created_at > now() - make_interval(days => @days)", conn);
|
||||
|
||||
searchCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
@@ -244,11 +424,346 @@ internal sealed class SearchQualityMonitor
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load search quality metrics");
|
||||
return BuildFallbackMetrics(tenantId, days, period);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private static int ResolvePeriodDays(string period)
|
||||
{
|
||||
return period switch
|
||||
{
|
||||
"24h" => 1,
|
||||
"30d" => 30,
|
||||
_ => 7,
|
||||
};
|
||||
}
|
||||
|
||||
private SearchQualityMetricsEntry BuildFallbackMetrics(string tenantId, int days, string period)
|
||||
{
|
||||
var window = TimeSpan.FromDays(Math.Max(1, days));
|
||||
var events = _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
|
||||
.Select(item => item.Event)
|
||||
.ToArray();
|
||||
|
||||
var totalSearches = events.Count(evt =>
|
||||
evt.EventType.Equals("query", StringComparison.OrdinalIgnoreCase) ||
|
||||
evt.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase));
|
||||
var zeroResults = events.Count(evt => evt.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var avgResultCount = events
|
||||
.Where(evt => evt.EventType.Equals("query", StringComparison.OrdinalIgnoreCase) || evt.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(evt => evt.ResultCount.HasValue)
|
||||
.Select(evt => evt.ResultCount!.Value)
|
||||
.DefaultIfEmpty(0)
|
||||
.Average();
|
||||
|
||||
var feedbackSignals = GetFallbackFeedback(tenantId, window)
|
||||
.Select(item => item.Entry.Signal)
|
||||
.ToArray();
|
||||
var helpfulCount = feedbackSignals.Count(signal => signal.Equals("helpful", StringComparison.Ordinal));
|
||||
var feedbackScore = feedbackSignals.Length == 0
|
||||
? 0d
|
||||
: (double)helpfulCount / feedbackSignals.Length * 100d;
|
||||
|
||||
return new SearchQualityMetricsEntry
|
||||
{
|
||||
TotalSearches = totalSearches,
|
||||
ZeroResultRate = totalSearches == 0 ? 0d : Math.Round((double)zeroResults / totalSearches * 100d, 1),
|
||||
AvgResultCount = Math.Round(avgResultCount, 1),
|
||||
FeedbackScore = Math.Round(feedbackScore, 1),
|
||||
Period = period,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AlertCandidate>> LoadZeroResultCandidatesAsync(
|
||||
string tenantId,
|
||||
TimeSpan window,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var candidates = new List<AlertCandidate>();
|
||||
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);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT query, COUNT(*)::int AS occurrence_count, MIN(created_at), MAX(created_at)
|
||||
FROM advisoryai.search_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND event_type = 'zero_result'
|
||||
AND created_at > now() - make_interval(days => @days)
|
||||
GROUP BY query
|
||||
HAVING COUNT(*) >= @threshold
|
||||
ORDER BY occurrence_count DESC", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("days", days);
|
||||
cmd.Parameters.AddWithValue("threshold", ZeroResultAlertThreshold);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
candidates.Add(new AlertCandidate(
|
||||
reader.GetString(0),
|
||||
reader.GetInt32(1),
|
||||
new DateTimeOffset(reader.GetDateTime(2), TimeSpan.Zero),
|
||||
new DateTimeOffset(reader.GetDateTime(3), TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load zero-result alert candidates from database.");
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackCandidates = _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
|
||||
.Where(item => item.Event.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Event.Query))
|
||||
.GroupBy(item => item.Event.Query.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => new AlertCandidate(
|
||||
group.Key,
|
||||
group.Count(),
|
||||
group.Min(item => item.RecordedAt),
|
||||
group.Max(item => item.RecordedAt)))
|
||||
.Where(candidate => candidate.OccurrenceCount >= ZeroResultAlertThreshold)
|
||||
.OrderByDescending(candidate => candidate.OccurrenceCount)
|
||||
.ToArray();
|
||||
|
||||
return fallbackCandidates;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AlertCandidate>> LoadNegativeFeedbackCandidatesAsync(
|
||||
string tenantId,
|
||||
TimeSpan window,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var candidates = new List<AlertCandidate>();
|
||||
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);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT query, COUNT(*)::int AS occurrence_count, MIN(created_at), MAX(created_at)
|
||||
FROM advisoryai.search_feedback
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND signal = 'not_helpful'
|
||||
AND created_at > now() - make_interval(days => @days)
|
||||
GROUP BY query
|
||||
HAVING COUNT(*) >= @threshold
|
||||
ORDER BY occurrence_count DESC", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("days", days);
|
||||
cmd.Parameters.AddWithValue("threshold", NegativeFeedbackAlertThreshold);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
candidates.Add(new AlertCandidate(
|
||||
reader.GetString(0),
|
||||
reader.GetInt32(1),
|
||||
new DateTimeOffset(reader.GetDateTime(2), TimeSpan.Zero),
|
||||
new DateTimeOffset(reader.GetDateTime(3), TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load negative-feedback alert candidates from database.");
|
||||
}
|
||||
}
|
||||
|
||||
return GetFallbackFeedback(tenantId, window)
|
||||
.Where(item => item.Entry.Signal.Equals("not_helpful", StringComparison.Ordinal))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Entry.Query))
|
||||
.GroupBy(item => item.Entry.Query.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => new AlertCandidate(
|
||||
group.Key,
|
||||
group.Count(),
|
||||
group.Min(item => item.CreatedAt),
|
||||
group.Max(item => item.CreatedAt)))
|
||||
.Where(candidate => candidate.OccurrenceCount >= NegativeFeedbackAlertThreshold)
|
||||
.OrderByDescending(candidate => candidate.OccurrenceCount)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task UpsertAlertAsync(
|
||||
string tenantId,
|
||||
string alertType,
|
||||
string query,
|
||||
int occurrenceCount,
|
||||
DateTimeOffset firstSeen,
|
||||
DateTimeOffset lastSeen,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var findCmd = new NpgsqlCommand(@"
|
||||
SELECT alert_id
|
||||
FROM advisoryai.search_quality_alerts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND alert_type = @alert_type
|
||||
AND query = @query
|
||||
AND status <> 'resolved'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1", conn);
|
||||
|
||||
findCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
findCmd.Parameters.AddWithValue("alert_type", alertType);
|
||||
findCmd.Parameters.AddWithValue("query", query);
|
||||
|
||||
var existingId = await findCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
if (existingId is Guid alertId)
|
||||
{
|
||||
await using var updateCmd = new NpgsqlCommand(@"
|
||||
UPDATE advisoryai.search_quality_alerts
|
||||
SET occurrence_count = @occurrence_count,
|
||||
first_seen = LEAST(first_seen, @first_seen),
|
||||
last_seen = GREATEST(last_seen, @last_seen),
|
||||
status = 'open',
|
||||
resolution = NULL
|
||||
WHERE alert_id = @alert_id", conn);
|
||||
|
||||
updateCmd.Parameters.AddWithValue("alert_id", alertId);
|
||||
updateCmd.Parameters.AddWithValue("occurrence_count", occurrenceCount);
|
||||
updateCmd.Parameters.AddWithValue("first_seen", firstSeen.UtcDateTime);
|
||||
updateCmd.Parameters.AddWithValue("last_seen", lastSeen.UtcDateTime);
|
||||
await updateCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var insertCmd = new NpgsqlCommand(@"
|
||||
INSERT INTO advisoryai.search_quality_alerts
|
||||
(tenant_id, alert_type, query, occurrence_count, first_seen, last_seen, status)
|
||||
VALUES
|
||||
(@tenant_id, @alert_type, @query, @occurrence_count, @first_seen, @last_seen, 'open')", conn);
|
||||
|
||||
insertCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
insertCmd.Parameters.AddWithValue("alert_type", alertType);
|
||||
insertCmd.Parameters.AddWithValue("query", query);
|
||||
insertCmd.Parameters.AddWithValue("occurrence_count", occurrenceCount);
|
||||
insertCmd.Parameters.AddWithValue("first_seen", firstSeen.UtcDateTime);
|
||||
insertCmd.Parameters.AddWithValue("last_seen", lastSeen.UtcDateTime);
|
||||
await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to upsert quality alert in database; applying in-memory fallback.");
|
||||
}
|
||||
}
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var existingIndex = _fallbackAlerts.FindIndex(entry =>
|
||||
entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.AlertType.Equals(alertType, StringComparison.Ordinal) &&
|
||||
entry.Query.Equals(query, StringComparison.OrdinalIgnoreCase) &&
|
||||
!entry.Status.Equals("resolved", StringComparison.Ordinal));
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
var existing = _fallbackAlerts[existingIndex];
|
||||
_fallbackAlerts[existingIndex] = new SearchQualityAlertEntry
|
||||
{
|
||||
AlertId = existing.AlertId,
|
||||
TenantId = existing.TenantId,
|
||||
AlertType = existing.AlertType,
|
||||
Query = existing.Query,
|
||||
OccurrenceCount = occurrenceCount,
|
||||
FirstSeen = existing.FirstSeen <= firstSeen.UtcDateTime ? existing.FirstSeen : firstSeen.UtcDateTime,
|
||||
LastSeen = existing.LastSeen >= lastSeen.UtcDateTime ? existing.LastSeen : lastSeen.UtcDateTime,
|
||||
Status = "open",
|
||||
Resolution = null,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_fallbackAlerts.Add(new SearchQualityAlertEntry
|
||||
{
|
||||
AlertId = Guid.NewGuid().ToString("D"),
|
||||
TenantId = tenantId,
|
||||
AlertType = alertType,
|
||||
Query = query,
|
||||
OccurrenceCount = occurrenceCount,
|
||||
FirstSeen = firstSeen.UtcDateTime,
|
||||
LastSeen = lastSeen.UtcDateTime,
|
||||
Status = "open",
|
||||
Resolution = null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreFallbackFeedback(SearchFeedbackEntry entry, DateTimeOffset createdAt)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
_fallbackFeedback.Add((entry, createdAt));
|
||||
if (_fallbackFeedback.Count > 10_000)
|
||||
{
|
||||
_fallbackFeedback.RemoveRange(0, _fallbackFeedback.Count - 10_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<(SearchFeedbackEntry Entry, DateTimeOffset CreatedAt)> GetFallbackFeedback(string tenantId, TimeSpan window)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - window;
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackFeedback
|
||||
.Where(item => item.Entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(item => item.CreatedAt >= cutoff)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static SearchQualityAlertEntry CloneAlertEntry(SearchQualityAlertEntry source)
|
||||
{
|
||||
return new SearchQualityAlertEntry
|
||||
{
|
||||
AlertId = source.AlertId,
|
||||
TenantId = source.TenantId,
|
||||
AlertType = source.AlertType,
|
||||
Query = source.Query,
|
||||
OccurrenceCount = source.OccurrenceCount,
|
||||
FirstSeen = source.FirstSeen,
|
||||
LastSeen = source.LastSeen,
|
||||
Status = source.Status,
|
||||
Resolution = source.Resolution,
|
||||
CreatedAt = source.CreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private readonly record struct AlertCandidate(
|
||||
string Query,
|
||||
int OccurrenceCount,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastSeen);
|
||||
|
||||
// ----- Validation helpers -----
|
||||
|
||||
public static bool IsValidSignal(string? signal)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
|
||||
internal sealed class SearchQualityMonitorBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly SearchQualityMonitor _monitor;
|
||||
private readonly ILogger<SearchQualityMonitorBackgroundService> _logger;
|
||||
|
||||
public SearchQualityMonitorBackgroundService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
SearchQualityMonitor monitor,
|
||||
ILogger<SearchQualityMonitorBackgroundService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_monitor = monitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.SearchQualityMonitorEnabled)
|
||||
{
|
||||
_logger.LogDebug("Search quality monitor background loop is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(Math.Max(30, _options.SearchQualityMonitorIntervalSeconds));
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var refreshed = await _monitor.RefreshAlertsForKnownTenantsAsync(stoppingToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Search quality monitor refreshed alerts for {TenantCount} tenants.", refreshed);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Search quality monitor background refresh failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ public static class UnifiedSearchServiceCollectionExtensions
|
||||
services.TryAddSingleton<UnifiedSearchIndexer>();
|
||||
services.TryAddSingleton<IUnifiedSearchIndexer>(provider => provider.GetRequiredService<UnifiedSearchIndexer>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, UnifiedSearchIndexRefreshService>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, SearchQualityMonitorBackgroundService>());
|
||||
|
||||
// Telemetry
|
||||
services.TryAddSingleton<IUnifiedSearchTelemetrySink, LoggingUnifiedSearchTelemetrySink>();
|
||||
|
||||
Reference in New Issue
Block a user