using System.Text.Json; using Microsoft.EntityFrameworkCore; using Prometheus; using StackExchange.Redis; public class News2Detector { private const int News2TtlSeconds = 14400; // 4 hours private static readonly JsonSerializerOptions CachedParamJsonOptions = new() { PropertyNameCaseInsensitive = true }; private readonly IConnectionMultiplexer _redis; private readonly IServiceProvider _services; private readonly ClinicalMetrics _metrics; private readonly ILogger _logger; public News2Detector( IConnectionMultiplexer redis, IServiceProvider services, ClinicalMetrics metrics, ILogger logger) { _redis = redis; _services = services; _metrics = metrics; _logger = logger; } public async Task ProcessObservationAsync( Guid encounterId, Guid patientId, string observationCode, decimal value, CancellationToken ct = default) { if (!News2Calculator.IsNews2Code(observationCode)) return News2Result.NotNews2Code; using var timer = _metrics.News2ScoringDuration.NewTimer(); var cache = _redis.GetDatabase(); // GCS components are cached by GcsDetector — trigger re-score only if (!GcsCalculator.IsGcsCode(observationCode)) { var individualScore = News2Calculator.ScoreParameter(observationCode, value); var paramData = JsonSerializer.Serialize(new { value, score = individualScore, recordedAt = DateTimeOffset.UtcNow }, CachedParamJsonOptions); await cache.StringSetAsync( News2Calculator.ParameterKey(encounterId, observationCode), paramData, TimeSpan.FromSeconds(News2TtlSeconds)); } return await TryComputeScoreAsync(encounterId, patientId, ct); } private async Task TryComputeScoreAsync( Guid encounterId, Guid patientId, CancellationToken ct) { var cache = _redis.GetDatabase(); var allKeys = News2Calculator.AllParameterKeys(encounterId); var allValues = await cache.StringGetAsync(allKeys); var scores = new int?[7]; for (int i = 0; i < 7; i++) { if (i == 4) // consciousness — GCS-first, AVPU-fallback { scores[4] = await ResolveConsciousnessScoreAsync(encounterId); if (scores[4] is null) { var present = allValues.Count(v => v.HasValue) + 0; _logger.LogDebug( "NEWS2 incomplete for encounter {Id}: consciousness missing ({Present}/7 present)", encounterId, present); return News2Result.IncompleteParameters(present); } continue; } if (!allValues[i].HasValue) { _logger.LogDebug( "NEWS2 incomplete for encounter {Id}: {Code} missing ({Present}/7 present)", encounterId, News2Calculator.ParameterCodes[i], allValues.Count(v => v.HasValue)); return News2Result.IncompleteParameters(allValues.Count(v => v.HasValue)); } var cached = JsonSerializer.Deserialize(allValues[i]!, CachedParamJsonOptions); scores[i] = cached?.Score; } var paramScores = scores.Select(s => s!.Value).ToArray(); var totalScore = paramScores.Sum(); var hasSingleParamThree = paramScores.Any(s => s == 3); var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree); await PersistScoreAsync( encounterId, patientId, totalScore, riskLevel, paramScores, hasSingleParamThree, ct); var alertCreated = false; if (riskLevel == "HIGH") { alertCreated = await TryCreateAlertAsync( encounterId, patientId, AlertType.News2Emergency, AlertSeverity.Critical, totalScore, riskLevel, paramScores, ct); } else if (riskLevel == "MEDIUM" || riskLevel == "LOW_MEDIUM") { alertCreated = await TryCreateAlertAsync( encounterId, patientId, AlertType.News2Warning, AlertSeverity.Warning, totalScore, riskLevel, paramScores, ct); } return new News2Result( News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree); } private async Task ResolveConsciousnessScoreAsync(Guid encounterId) { var cache = _redis.GetDatabase(); var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId)); if (gcsValues.All(v => v.HasValue)) { var eye = decimal.Parse(gcsValues[0]!); var verbal = decimal.Parse(gcsValues[1]!); var motor = decimal.Parse(gcsValues[2]!); var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value; return News2Calculator.ScoreConsciousnessFromGcs(total); } var avpuVal = await cache.StringGetAsync( News2Calculator.ParameterKey(encounterId, "AVPU")); if (!avpuVal.HasValue) return null; var cached = JsonSerializer.Deserialize(avpuVal!, CachedParamJsonOptions); return cached?.Score; } private async Task PersistScoreAsync( Guid encounterId, Guid patientId, int totalScore, string riskLevel, int[] paramScores, bool hasSingleParamThree, CancellationToken ct) { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var score = new News2Score { Id = Guid.NewGuid(), EncounterId = encounterId, PatientId = patientId, TotalScore = totalScore, RiskLevel = riskLevel, RespRateScore = paramScores[0], Spo2Score = paramScores[1], SystolicBpScore = paramScores[2], HeartRateScore = paramScores[3], ConsciousnessScore = paramScores[4], TemperatureScore = paramScores[5], SupplementalO2Score = paramScores[6], HasSingleParamThree = hasSingleParamThree, CalculatedAt = DateTimeOffset.UtcNow }; db.News2Scores.Add(score); await db.SaveChangesAsync(ct); _metrics.News2ScoresTotal.WithLabels(riskLevel).Inc(); return score.Id; } private async Task TryCreateAlertAsync( Guid encounterId, Guid patientId, AlertType alertType, AlertSeverity severity, int totalScore, string riskLevel, int[] paramScores, CancellationToken ct) { if (alertType == AlertType.News2Warning) { var suppression = _services.GetRequiredService(); if (await suppression.IsSuppressedAsync(encounterId, alertType, ct)) { _logger.LogDebug("NEWS2_WARNING suppressed for encounter {Id}", encounterId); return false; } } AlertCreationGuard.EnsureAllowed(alertType); using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await using var tx = await db.Database.BeginTransactionAsync(ct); var alertId = Guid.NewGuid(); var triggeredAt = DateTimeOffset.UtcNow; var details = BuildDetails(totalScore, riskLevel, paramScores); var correlation = scope.ServiceProvider.GetRequiredService(); var annotatedParts = new List(); foreach (var code in News2Calculator.ParameterCodes) { var part = await correlation.TryAnnotateDetailsAsync( encounterId, code, "", ct); if (part.StartsWith(" — note:")) annotatedParts.Add(part.TrimStart(' ', '—').Trim()); } if (annotatedParts.Count > 0) details += " — " + string.Join("; ", annotatedParts.Distinct()); var affected = await db.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO clinical_alerts (id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at) SELECT {alertId}, {encounterId}, {patientId}, {alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt} WHERE NOT EXISTS ( SELECT 1 FROM clinical_alerts WHERE encounter_id = {encounterId} AND alert_type = {alertType.ToDbString()} AND status IN ('OPEN', 'ESCALATED') ) """, 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 = severity.ToDbString(), triggeredAt, news2Score = totalScore, news2RiskLevel = riskLevel, partitionKey = encounterId.ToString() }), PartitionKey = encounterId.ToString(), CreatedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(ct); await tx.CommitAsync(ct); _metrics.ClinicalAlertsTotal .WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc(); _logger.LogWarning( "NEWS2 alert {AlertType} created for encounter {EncounterId} — score={Score} risk={Risk}", alertType.ToDbString(), encounterId, totalScore, riskLevel); return true; } private static string BuildDetails(int totalScore, string riskLevel, int[] paramScores) { var components = News2Calculator.ParameterCodes .Zip(paramScores, (code, score) => $"{code}={score}") .ToArray(); return $"NEWS2 score {totalScore} ({riskLevel}): {string.Join(", ", components)}."; } }