feature: Explainable Alerts
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
public static class News2ContributorBuilder
|
||||
{
|
||||
private static readonly Dictionary<string, (string Label, string NormalRange)> Labels = new()
|
||||
{
|
||||
["RESP_RATE"] = ("Respiratory Rate", "12-20 breaths/min"),
|
||||
["SPO2"] = ("SpO2", "96-100%"),
|
||||
["SYSTOLIC_BP"] = ("Systolic BP", "101-219 mmHg"),
|
||||
["HEART_RATE"] = ("Heart Rate", "51-90 bpm"),
|
||||
["AVPU"] = ("Consciousness", "Alert"),
|
||||
["TEMP_C"] = ("Temperature", "36.1-38.0 °C"),
|
||||
["SUPPLEMENTAL_O2"] = ("Supplemental O2", "Room air"),
|
||||
};
|
||||
|
||||
public static List<ScoreContributor> Build(int[] paramScores, string?[]? rawValues = null)
|
||||
{
|
||||
var contributors = new List<ScoreContributor>();
|
||||
for (int i = 0; i < News2Calculator.ParameterCodes.Count; i++)
|
||||
{
|
||||
var code = News2Calculator.ParameterCodes[i];
|
||||
var (label, range) = Labels[code];
|
||||
contributors.Add(new ScoreContributor
|
||||
{
|
||||
Parameter = label,
|
||||
Points = paramScores[i],
|
||||
RawValue = rawValues?[i],
|
||||
NormalRange = range
|
||||
});
|
||||
}
|
||||
return contributors;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user