feature: Explainable Alerts

This commit is contained in:
voltsrage
2026-06-25 00:25:31 +08:00
parent 279add1e45
commit 666d683d67
61 changed files with 9553 additions and 125 deletions
+50 -25
View File
@@ -69,11 +69,14 @@ public class News2Detector
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;
@@ -96,9 +99,11 @@ public class News2Detector
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);
@@ -123,17 +128,17 @@ public class News2Detector
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.News2Emergency, AlertSeverity.Critical,
totalScore, riskLevel, paramScores, ct);
totalScore, riskLevel, paramScores, contributors, ct);
}
else if (riskLevel == "MEDIUM" || riskLevel == "LOW_MEDIUM")
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.News2Warning, AlertSeverity.Warning,
totalScore, riskLevel, paramScores, ct);
totalScore, riskLevel, paramScores, contributors, ct);
}
return new News2Result(
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree, contributors);
}
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
@@ -159,6 +164,28 @@ public class News2Detector
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,
@@ -198,6 +225,7 @@ public class News2Detector
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
int totalScore, string riskLevel, int[] paramScores,
List<ScoreContributor> contributors,
CancellationToken ct)
{
if (alertType == AlertType.News2Warning)
@@ -222,31 +250,26 @@ public class News2Detector
var details = BuildDetails(totalScore, riskLevel, paramScores);
var correlation = scope.ServiceProvider.GetRequiredService<MedicationCorrelationHelper>();
var annotatedParts = new List<string>();
foreach (var code in News2Calculator.ParameterCodes)
MedicationContext? medication = null;
foreach (var (code, score) in News2Calculator.ParameterCodes.Zip(paramScores))
{
var part = await correlation.TryAnnotateDetailsAsync(
encounterId, code, "", ct);
if (part.StartsWith(" — note:"))
annotatedParts.Add(part.TrimStart(' ', '—').Trim());
if (score <= 0)
continue;
medication = await correlation.TryGetContextAsync(encounterId, code, ct);
if (medication is not null)
break;
}
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);
var explanation = AlertExplanationBuilder.Build(
"NEWS2", totalScore, contributors, medication: medication);
if (affected == 0)
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;
@@ -256,16 +279,18 @@ public class News2Detector
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
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(),