feature: Trend Detection & Alert Suppression Windows

This commit is contained in:
voltsrage
2026-06-18 21:18:13 +08:00
parent e6f7989298
commit 3c54feb38a
31 changed files with 2751 additions and 18 deletions
+39 -1
View File
@@ -1,11 +1,17 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
public class AlertService : IAlertService
{
private readonly AppDbContext _db;
private readonly IServiceProvider _services;
public AlertService(AppDbContext db) => _db = db;
public AlertService(AppDbContext db, IServiceProvider services)
{
_db = db;
_services = services;
}
public async Task<PagedResult<ClinicalAlert>> ListByEncounterAsync(
Guid encounterId, AlertStatus? status, int page, int pageSize)
@@ -100,10 +106,42 @@ public class AlertService : IAlertService
CreatedAt = DateTimeOffset.UtcNow
});
if (alert.AlertType.IsSuppressible())
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
var options = _services.GetRequiredService<IOptions<SuppressionOptions>>().Value;
var windowMinutes = await ResolveSuppressionWindowMinutesAsync(
alert.AlertType, options.DefaultWindowMinutes);
await suppression.SetSuppressionAsync(
alert.EncounterId,
alert.AlertType,
TimeSpan.FromMinutes(windowMinutes));
}
await _db.SaveChangesAsync();
return alert;
}
private async Task<int> ResolveSuppressionWindowMinutesAsync(
AlertType alertType, int defaultWindowMinutes)
{
if (alertType == AlertType.News2Warning)
return defaultWindowMinutes;
var observationCode = alertType.ObservationCodeForWarning();
if (observationCode is null)
return defaultWindowMinutes;
var overrideMinutes = await _db.AlertThresholds
.AsNoTracking()
.Where(t => t.ObservationCode == observationCode)
.Select(t => t.SuppressionWindowMinutes)
.FirstOrDefaultAsync();
return overrideMinutes ?? defaultWindowMinutes;
}
public async Task<ClinicalAlert> ResolveAsync(Guid id)
{
var alert = await _db.ClinicalAlerts.FindAsync(id);
@@ -0,0 +1,31 @@
using StackExchange.Redis;
public class AlertSuppressionService : IAlertSuppressionService
{
private readonly IConnectionMultiplexer _redis;
private readonly ClinicalMetrics _metrics;
public AlertSuppressionService(IConnectionMultiplexer redis, ClinicalMetrics metrics)
{
_redis = redis;
_metrics = metrics;
}
public static string SuppressionKey(Guid encounterId, AlertType alertType) =>
$"suppress:{encounterId}:{alertType.ToDbString()}";
public async Task SetSuppressionAsync(
Guid encounterId, AlertType alertType, TimeSpan ttl, CancellationToken ct = default)
{
var cache = _redis.GetDatabase();
await cache.StringSetAsync(SuppressionKey(encounterId, alertType), "1", ttl);
_metrics.AlertSuppressionsTotal.WithLabels(alertType.ToDbString()).Inc();
}
public async Task<bool> IsSuppressedAsync(
Guid encounterId, AlertType alertType, CancellationToken ct = default)
{
var cache = _redis.GetDatabase();
return await cache.KeyExistsAsync(SuppressionKey(encounterId, alertType));
}
}
@@ -0,0 +1,5 @@
public interface IAlertSuppressionService
{
Task SetSuppressionAsync(Guid encounterId, AlertType alertType, TimeSpan ttl, CancellationToken ct = default);
Task<bool> IsSuppressedAsync(Guid encounterId, AlertType alertType, CancellationToken ct = default);
}
@@ -35,6 +35,15 @@ public class WarningEvaluator
// critical alerts are created synchronously by the ingest path.
if (IsCriticalBreach(value, threshold)) return false;
var alertType = AlertTypeExtensions.WarningFor(observationCode);
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
{
_logger.LogDebug(
"Warning alert suppressed for encounter {Id} type {Type}", encounterId, alertType);
return false;
}
return await TryCreateWarningAlertAsync(
observationId, encounterId, patientId, observationCode, value, threshold, ct);
}