139 lines
5.2 KiB
C#
139 lines
5.2 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using StackExchange.Redis;
|
|
|
|
public class WarningEvaluator
|
|
{
|
|
private readonly IConnectionMultiplexer _redis;
|
|
private readonly IServiceProvider _services;
|
|
private readonly ILogger<WarningEvaluator> _logger;
|
|
|
|
public WarningEvaluator(
|
|
IConnectionMultiplexer redis,
|
|
IServiceProvider services,
|
|
ILogger<WarningEvaluator> logger)
|
|
{
|
|
_redis = redis;
|
|
_services = services;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<bool> EvaluateAsync(
|
|
Guid observationId,
|
|
Guid encounterId,
|
|
Guid patientId,
|
|
string observationCode,
|
|
decimal value,
|
|
CancellationToken ct = default)
|
|
{
|
|
var threshold = await LoadThresholdAsync(observationCode);
|
|
if (threshold is null) return false;
|
|
|
|
if (!IsWarningBreach(value, threshold)) return false;
|
|
|
|
// Do not create a warning if the value is also a critical breach —
|
|
// critical alerts are created synchronously by the ingest path.
|
|
if (IsCriticalBreach(value, threshold)) return false;
|
|
|
|
return await TryCreateWarningAlertAsync(
|
|
observationId, encounterId, patientId, observationCode, value, threshold, ct);
|
|
}
|
|
|
|
private static bool IsWarningBreach(decimal value, ThresholdCacheEntry t) =>
|
|
(t.WarningHigh.HasValue && value > t.WarningHigh.Value) ||
|
|
(t.WarningLow.HasValue && value < t.WarningLow.Value);
|
|
|
|
private static bool IsCriticalBreach(decimal value, ThresholdCacheEntry t) =>
|
|
(t.CriticalLow.HasValue && value < t.CriticalLow.Value) ||
|
|
(t.CriticalHigh.HasValue && value > t.CriticalHigh.Value);
|
|
|
|
private async Task<ThresholdCacheEntry?> LoadThresholdAsync(string observationCode)
|
|
{
|
|
var cache = _redis.GetDatabase();
|
|
var cached = await cache.StringGetAsync($"threshold:{observationCode}");
|
|
if (cached.HasValue)
|
|
return JsonSerializer.Deserialize<ThresholdCacheEntry>(cached!);
|
|
return null;
|
|
}
|
|
|
|
// Idempotent INSERT: prevents duplicate warning alerts for the same observation.
|
|
// The WHERE NOT EXISTS checks for an open warning alert of the same type for the
|
|
// same encounter. Unlike critical alerts (one per encounter), warning alerts are
|
|
// expected to recur — but not for every single observation in a series. If the
|
|
// patient's heart rate stays at 105 bpm for an hour, one WARNING_HEART_RATE is
|
|
// sufficient until acknowledged or resolved.
|
|
private async Task<bool> TryCreateWarningAlertAsync(
|
|
Guid observationId,
|
|
Guid encounterId,
|
|
Guid patientId,
|
|
string observationCode,
|
|
decimal value,
|
|
ThresholdCacheEntry threshold,
|
|
CancellationToken ct)
|
|
{
|
|
using var scope = _services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
await using var tx = await db.Database.BeginTransactionAsync(ct);
|
|
|
|
var alertType = AlertTypeExtensions.WarningFor(observationCode);
|
|
var alertId = Guid.NewGuid();
|
|
var triggeredAt = DateTimeOffset.UtcNow;
|
|
var details = BuildWarningDetails(observationCode, value, threshold);
|
|
|
|
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
|
|
INSERT INTO clinical_alerts
|
|
(id, encounter_id, patient_id, observation_id, alert_type, severity, details, status, triggered_at)
|
|
SELECT {alertId}, {encounterId}, {patientId}, {observationId},
|
|
{alertType.ToDbString()}, 'WARNING', {details}, 'OPEN', {triggeredAt}
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM clinical_alerts
|
|
WHERE encounter_id = {encounterId}
|
|
AND alert_type = {alertType.ToDbString()}
|
|
AND status IN ('OPEN', 'ACKNOWLEDGED')
|
|
)
|
|
""", ct);
|
|
|
|
if (affected == 0)
|
|
{
|
|
await tx.RollbackAsync(ct);
|
|
return false;
|
|
}
|
|
|
|
db.OutboxEvents.Add(new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "alert.generated",
|
|
Payload = JsonSerializer.Serialize(new
|
|
{
|
|
alertId,
|
|
encounterId,
|
|
patientId,
|
|
alertType = alertType.ToDbString(),
|
|
severity = "Warning",
|
|
details,
|
|
triggeredAt,
|
|
partitionKey = encounterId.ToString()
|
|
}),
|
|
PartitionKey = encounterId.ToString(),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
await tx.CommitAsync(ct);
|
|
|
|
_logger.LogInformation(
|
|
"WARNING alert {AlertId} created for encounter {EncounterId} — {Code}={Value}",
|
|
alertId, encounterId, observationCode, value);
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string BuildWarningDetails(
|
|
string code, decimal value, ThresholdCacheEntry t)
|
|
{
|
|
if (t.WarningHigh.HasValue && value > t.WarningHigh.Value)
|
|
return $"{code} value {value} is above warning high of {t.WarningHigh}.";
|
|
return $"{code} value {value} is below warning low of {t.WarningLow}.";
|
|
}
|
|
} |