Files
vigilcare-clinical/VigilCareClinicalAPI/News2/News2Detector.cs
T
2026-06-25 00:25:31 +08:00

320 lines
12 KiB
C#

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<News2Detector> _logger;
public News2Detector(
IConnectionMultiplexer redis,
IServiceProvider services,
ClinicalMetrics metrics,
ILogger<News2Detector> logger)
{
_redis = redis;
_services = services;
_metrics = metrics;
_logger = logger;
}
public async Task<News2Result> 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<News2Result> 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<News2CachedParam>(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<AppDbContext>();
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<int?> 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<News2CachedParam>(avpuVal!, CachedParamJsonOptions);
return cached?.Score;
}
private async Task<string?> 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<News2CachedParam>(avpuVal!, CachedParamJsonOptions);
return cached?.Value.ToString();
}
private async Task<Guid> 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<AppDbContext>();
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<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
int totalScore, string riskLevel, int[] paramScores,
List<ScoreContributor> contributors,
CancellationToken ct)
{
if (alertType == AlertType.News2Warning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
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<AppDbContext>();
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<MedicationCorrelationHelper>();
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)}.";
}
}