feature: Explainable Alerts
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user