using System.Text.Json; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; public class WarningEvaluator { private readonly IConnectionMultiplexer _redis; private readonly IServiceProvider _services; private readonly ILogger _logger; public WarningEvaluator( IConnectionMultiplexer redis, IServiceProvider services, ILogger logger) { _redis = redis; _services = services; _logger = logger; } public async Task 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 LoadThresholdAsync(string observationCode) { var cache = _redis.GetDatabase(); var cached = await cache.StringGetAsync($"threshold:{observationCode}"); if (cached.HasValue) return JsonSerializer.Deserialize(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 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(); 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", 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}."; } }