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
+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(),