Files
vigilcare-clinical/VigilCareClinicalAPI/BackgroundServices/AlertQualityAggregatorService.cs
T
2026-06-24 03:01:00 +08:00

176 lines
7.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}