documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF

This commit is contained in:
master
2026-02-25 01:24:07 +02:00
parent b07d27772e
commit 4db038123b
9090 changed files with 4836 additions and 2909 deletions

View File

@@ -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++)
{

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
[
[
{
"checkCode": "check.core.disk.space",
"title": "Speicherplatzverfügbarkeit",
@@ -168,3 +168,4 @@
]
}
]

View File

@@ -1,4 +1,4 @@
[
[
{
"checkCode": "check.core.disk.space",
"title": "Disponibilité de l'espace disque",

View File

@@ -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(

View File

@@ -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)

View File

@@ -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;
}
}
}
}

View File

@@ -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>();