Run initial test for Climate Resilience Verification Suite

Add first part of Alert Quality Analytics
This commit is contained in:
voltsrage
2026-06-24 03:01:00 +08:00
parent 032cd1d240
commit 185dc93fa1
50 changed files with 4116 additions and 55 deletions
@@ -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;
}