176 lines
7.7 KiB
C#
176 lines
7.7 KiB
C#
|
||
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Options;
|
||
|
||
public sealed class AlertQualityAggregatorService : BackgroundService
|
||
{
|
||
private readonly IServiceScopeFactory _scopes;
|
||
private readonly IOptions<AlertQualityOptions> _options;
|
||
private readonly ClinicalMetrics _metrics;
|
||
private readonly ILogger<AlertQualityAggregatorService> _logger;
|
||
|
||
public AlertQualityAggregatorService(
|
||
IServiceScopeFactory scopes,
|
||
IOptions<AlertQualityOptions> options,
|
||
ClinicalMetrics metrics,
|
||
ILogger<AlertQualityAggregatorService> 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<AppDbContext>();
|
||
var windowHours = Math.Max(1, _options.Value.WindowHours);
|
||
var windowEnd = AlignToHour(DateTimeOffset.UtcNow);
|
||
var windowStart = windowEnd.AddHours(-windowHours);
|
||
|
||
foreach (AlertType alertType in Enum.GetValues<AlertType>())
|
||
{
|
||
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;
|
||
} |