320 lines
12 KiB
C#
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)}.";
|
|
}
|
|
} |