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]; var rawValues = new string?[7]; for (int i = 0; i < 7; i++) { if (i == 4) // consciousness — GCS-first, AVPU-fallback { scores[4] = await ResolveConsciousnessScoreAsync(encounterId); rawValues[4] = await ResolveConsciousnessRawValueAsync(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; rawValues[i] = cached?.Value.ToString(); } var paramScores = scores.Select(s => s!.Value).ToArray(); var contributors = News2ContributorBuilder.Build(paramScores, rawValues); var totalScore = paramScores.Sum(); var hasSingleParamThree = paramScores.Any(s => s == 3); var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree); using (var scope = _services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); if (!await db.Encounters.AnyAsync(e => e.Id == encounterId, ct)) { _logger.LogWarning( "Skipping NEWS2 score for unknown encounter {EncounterId}", encounterId); return News2Result.EncounterNotFound; } } 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, contributors, ct); } else if (riskLevel == "MEDIUM" || riskLevel == "LOW_MEDIUM") { alertCreated = await TryCreateAlertAsync( encounterId, patientId, AlertType.News2Warning, AlertSeverity.Warning, totalScore, riskLevel, paramScores, contributors, ct); } return new News2Result( News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree, contributors); } 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 ResolveConsciousnessRawValueAsync(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]!); return GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value.ToString(); } var avpuVal = await cache.StringGetAsync( News2Calculator.ParameterKey(encounterId, "AVPU")); if (!avpuVal.HasValue) return null; var cached = JsonSerializer.Deserialize(avpuVal!, CachedParamJsonOptions); return cached?.Value.ToString(); } 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, List contributors, 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(); MedicationContext? medication = null; foreach (var (code, score) in News2Calculator.ParameterCodes.Zip(paramScores)) { if (score <= 0) continue; medication = await correlation.TryGetContextAsync(encounterId, code, ct); if (medication is not null) break; } var explanation = AlertExplanationBuilder.Build( "NEWS2", totalScore, contributors, medication: medication); var inserted = await ClinicalAlertFactory.TryInsertAsync( db, alertId, encounterId, patientId, alertType, severity, details, explanation, observationCode: null, triggeredAt, ct); if (!inserted) { await tx.RollbackAsync(ct); return false; } db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), Topic = "alert.generated", Payload = ClinicalAlertFactory.SerializeOutboxPayload(new { alertId, encounterId, patientId, alertType = alertType.ToDbString(), severity = severity.ToDbString(), details, triggeredAt, news2Score = totalScore, news2RiskLevel = riskLevel, explanation, 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)}."; } }