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
@@ -59,4 +59,14 @@ public static class TrendCalculator
_ =>
$"Rapid rise: {observationCode} rising at {ratePerMinute:F2}/min (current {currentValue})"
};
public static string DisplayName(string observationCode) => observationCode switch
{
"HEART_RATE" => "Heart Rate",
"RESP_RATE" => "Respiratory Rate",
"SYSTOLIC_BP" => "Systolic BP",
"TEMP_C" => "Temperature",
"SPO2" => "SpO2",
_ => observationCode
};
}
@@ -0,0 +1,26 @@
public static class TrendContextBuilder
{
public static TrendContext? BuildContext(
string observationCode,
IReadOnlyList<TrendHistoryEntry> history,
decimal currentValue)
{
if (history.Count < 2) return null;
var oldest = history.First();
var newest = history.Last();
var duration = newest.RecordedAt - oldest.RecordedAt;
if (duration <= TimeSpan.Zero || oldest.Value == 0) return null;
var pctChange = (double)((newest.Value - oldest.Value) / oldest.Value * 100m);
var direction = pctChange >= 0 ? "Increasing" : "Decreasing";
return new TrendContext
{
Parameter = TrendCalculator.DisplayName(observationCode),
PercentChange = Math.Round(Math.Abs(pctChange), 1),
Duration = duration,
Direction = direction
};
}
}
+30 -17
View File
@@ -87,18 +87,32 @@ public class TrendDetector
}
}
var trendContext = TrendContextBuilder.BuildContext(observationCode, history, value);
var details = TrendCalculator.DescribeTrend(observationCode, rate.Value, value);
var contributor = new List<ScoreContributor>
{
new()
{
Parameter = TrendCalculator.DisplayName(observationCode),
Points = 0,
RawValue = value.ToString(),
NormalRange = null
}
};
var created = await TryCreateAlertAsync(
encounterId, patientId, observationCode, details, rate.Value, ct);
encounterId, patientId, observationCode, details, rate.Value,
contributor, trendContext, ct);
return new TrendResult(
created ? TrendOutcome.RapidDeterioration : TrendOutcome.AlertAlreadyOpen,
observationCode, rate, created);
observationCode, rate, created, trendContext);
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
string observationCode, string details, decimal ratePerMinute,
List<ScoreContributor> contributors, TrendContext? trendContext,
CancellationToken ct)
{
using var scope = _services.CreateScope();
@@ -110,21 +124,19 @@ public class TrendDetector
var triggeredAt = DateTimeOffset.UtcNow;
var fullDetails = $"{details} — velocity {ratePerMinute:F2}/min over {_options.WindowMinutes}min window.";
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, observation_code, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
'RAPID_DETERIORATION', 'WARNING', {fullDetails}, {observationCode}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = 'RAPID_DETERIORATION'
AND observation_code = {observationCode}
AND status IN ('OPEN', 'ESCALATED')
)
""", ct);
MedicationContext? medication = null;
var correlation = scope.ServiceProvider.GetRequiredService<MedicationCorrelationHelper>();
medication = await correlation.TryGetContextAsync(encounterId, observationCode, ct);
if (affected == 0)
var explanation = AlertExplanationBuilder.Build(
"Rapid deterioration", null, contributors, trendContext, medication);
var inserted = await ClinicalAlertFactory.TryInsertAsync(
db, alertId, encounterId, patientId,
AlertType.RapidDeterioration, AlertSeverity.Warning,
fullDetails, explanation, observationCode, triggeredAt, ct);
if (!inserted)
{
await tx.RollbackAsync(ct);
return false;
@@ -134,7 +146,7 @@ public class TrendDetector
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
Payload = ClinicalAlertFactory.SerializeOutboxPayload(new
{
alertId,
encounterId,
@@ -145,6 +157,7 @@ public class TrendDetector
triggeredAt,
observationCode,
ratePerMinute,
explanation,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),