using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; public sealed class AlertQualityAggregatorService : BackgroundService { private readonly IServiceScopeFactory _scopes; private readonly IOptions _options; private readonly ClinicalMetrics _metrics; private readonly ILogger _logger; public AlertQualityAggregatorService( IServiceScopeFactory scopes, IOptions options, ClinicalMetrics metrics, ILogger logger) { _scopes = scopes; _options = options; _metrics = metrics; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var interval = TimeSpan.FromMinutes(Math.Max(1, _options.Value.IntervalMinutes)); using var timer = new PeriodicTimer(interval); // Run once at startup, then on interval await AggregateAsync(stoppingToken); while (await timer.WaitForNextTickAsync(stoppingToken)) await AggregateAsync(stoppingToken); } private async Task AggregateAsync(CancellationToken ct) { try { await using var scope = _scopes.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var windowHours = Math.Max(1, _options.Value.WindowHours); var windowEnd = AlignToHour(DateTimeOffset.UtcNow); var windowStart = windowEnd.AddHours(-windowHours); foreach (AlertType alertType in Enum.GetValues()) { var alerts = await db.ClinicalAlerts .AsNoTracking() .Where(a => a.AlertType == alertType && a.TriggeredAt >= windowStart && a.TriggeredAt < windowEnd) .Select(a => new { a.Status, a.TriggeredAt, a.AcknowledgedAt, a.ResolvedAt }) .ToListAsync(ct); if (alerts.Count == 0) continue; var alertIds = await db.ClinicalAlerts .AsNoTracking() .Where(a => a.AlertType == alertType && a.TriggeredAt >= windowStart && a.TriggeredAt < windowEnd) .Select(a => a.Id) .ToListAsync(ct); var feedbacks = await db.AlertFeedbacks .AsNoTracking() .Where(f => alertIds.Contains(f.AlertId)) .Select(f => f.FeedbackType) .ToListAsync(ct); var total = alerts.Count; var acknowledged = alerts.Count(a => a.Status is AlertStatus.Acknowledged or AlertStatus.Resolved); var resolved = alerts.Count(a => a.Status == AlertStatus.Resolved); var escalated = alerts.Count(a => a.Status == AlertStatus.Escalated); var useful = feedbacks.Count(f => f == AlertFeedbackType.Useful); var falsePositive = feedbacks.Count(f => f == AlertFeedbackType.FalsePositive); var wouldAct = feedbacks.Count(f => f == AlertFeedbackType.WouldAct); var feedbackCount = feedbacks.Count; var ackDurations = alerts .Where(a => a.AcknowledgedAt.HasValue) .Select(a => (a.AcknowledgedAt!.Value - a.TriggeredAt).TotalSeconds) .ToList(); var resolveDurations = alerts .Where(a => a.ResolvedAt.HasValue && a.AcknowledgedAt.HasValue) .Select(a => (a.ResolvedAt!.Value - a.AcknowledgedAt!.Value).TotalSeconds) .ToList(); var metric = new AlertQualityMetric { Id = Guid.NewGuid(), AlertType = alertType, WindowStart = windowStart, WindowEnd = windowEnd, TotalAlerts = total, AcknowledgedCount = acknowledged, ResolvedCount = resolved, EscalatedCount = escalated, FeedbackUsefulCount = useful, FeedbackFalsePositiveCount = falsePositive, FeedbackWouldActCount = wouldAct, FeedbackCount = feedbackCount, AcknowledgementRate = SafeRate(acknowledged, total), FalsePositiveRate = SafeRate(falsePositive, feedbackCount), UsefulRate = SafeRate(useful, feedbackCount), WouldActRate = SafeRate(wouldAct, feedbackCount), AvgSecondsToAcknowledge = ackDurations.Count > 0 ? ackDurations.Average() : 0, AvgSecondsToResolution = resolveDurations.Count > 0 ? resolveDurations.Average() : 0, ComputedAt = DateTimeOffset.UtcNow }; var existing = await db.AlertQualityMetrics .FirstOrDefaultAsync(m => m.AlertType == alertType && m.WindowStart == windowStart && m.WindowEnd == windowEnd, ct); if (existing is null) db.AlertQualityMetrics.Add(metric); else { existing.TotalAlerts = metric.TotalAlerts; existing.AcknowledgedCount = metric.AcknowledgedCount; existing.ResolvedCount = metric.ResolvedCount; existing.EscalatedCount = metric.EscalatedCount; existing.FeedbackUsefulCount = metric.FeedbackUsefulCount; existing.FeedbackFalsePositiveCount = metric.FeedbackFalsePositiveCount; existing.FeedbackWouldActCount = metric.FeedbackWouldActCount; existing.FeedbackCount = metric.FeedbackCount; existing.AcknowledgementRate = metric.AcknowledgementRate; existing.FalsePositiveRate = metric.FalsePositiveRate; existing.UsefulRate = metric.UsefulRate; existing.WouldActRate = metric.WouldActRate; existing.AvgSecondsToAcknowledge = metric.AvgSecondsToAcknowledge; existing.AvgSecondsToResolution = metric.AvgSecondsToResolution; existing.ComputedAt = metric.ComputedAt; metric = existing; } await db.SaveChangesAsync(ct); var typeLabel = alertType.ToDbString(); _metrics.AlertAcknowledgementRate.WithLabels(typeLabel).Set(metric.AcknowledgementRate); _metrics.AlertFalsePositiveRate.WithLabels(typeLabel).Set(metric.FalsePositiveRate); _metrics.AlertUsefulRate.WithLabels(typeLabel).Set(metric.UsefulRate); _metrics.AlertAvgAckSeconds.WithLabels(typeLabel).Set(metric.AvgSecondsToAcknowledge); } _logger.LogDebug( "Alert quality aggregation complete for window {Start} – {End}", windowStart, windowEnd); } catch (Exception ex) { _logger.LogError(ex, "AlertQualityAggregatorService failed"); } } private static DateTimeOffset AlignToHour(DateTimeOffset value) => new(value.Year, value.Month, value.Day, value.Hour, 0, 0, value.Offset); private static double SafeRate(int numerator, int denominator) => denominator == 0 ? 0.0 : (double)numerator / denominator; }