Run initial test for Climate Resilience Verification Suite
Add first part of Alert Quality Analytics
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user