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
@@ -0,0 +1,71 @@
public static class AlertExplanationBuilder
{
public static AlertExplanation Build(
string scoringLabel,
int? totalScore,
List<ScoreContributor> contributors,
TrendContext? trend = null,
MedicationContext? medication = null)
{
return new AlertExplanation
{
ScoreContributors = contributors,
Trend = trend,
MedicationContext = medication,
NarrativeSummary = BuildNarrative(scoringLabel, totalScore, contributors, trend, medication)
};
}
private static string BuildNarrative(
string scoringLabel,
int? totalScore,
List<ScoreContributor> contributors,
TrendContext? trend,
MedicationContext? medication)
{
var elevated = contributors.Where(c => c.Points > 0).ToList();
if (elevated.Count == 0)
elevated = contributors;
var headline = totalScore.HasValue
? $"{scoringLabel} {totalScore} — "
: $"{scoringLabel} — ";
var contributorText = string.Join(", ",
elevated.Select(c => $"{c.Parameter.ToLowerInvariant()} (+{c.Points})"));
var parts = new List<string> { headline + contributorText };
if (trend is not null)
{
parts.Add(
$"{trend.Parameter} {trend.Direction.ToLowerInvariant()} " +
$"{trend.PercentChange:F0}% over {FormatDuration(trend.Duration)}.");
}
if (medication is not null)
{
var minutesAgo = (int)(DateTimeOffset.UtcNow - medication.AdministeredAt).TotalMinutes;
var medLine =
$"{medication.DrugName} {medication.Dose} {medication.Route} " +
$"administered {minutesAgo} minutes ago";
if (!string.IsNullOrEmpty(medication.RelevanceNote))
medLine += $" ({medication.RelevanceNote})";
parts.Add(medLine);
}
return string.Join(" ", parts);
}
private static string FormatDuration(TimeSpan duration)
{
if (duration.TotalHours >= 1)
{
var hours = (int)duration.TotalHours;
return $"{hours} hour{(hours == 1 ? "" : "s")}";
}
var minutes = (int)duration.TotalMinutes;
return $"{minutes} minute{(minutes == 1 ? "" : "s")}";
}
}
@@ -0,0 +1,70 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public static class ClinicalAlertFactory
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static string SerializeOutboxPayload(object payload) =>
JsonSerializer.Serialize(payload, JsonOptions);
/// <summary>
/// Idempotent INSERT. Returns true when a new row was created.
/// When <paramref name="observationCode"/> is null, dedupes on encounter + alert type only
/// (NEWS2, SOFA, GCS). When set, dedupes on encounter + alert type + observation code (trend).
/// </summary>
public static async Task<bool> TryInsertAsync(
AppDbContext db,
Guid alertId,
Guid encounterId,
Guid patientId,
AlertType alertType,
AlertSeverity severity,
string details,
AlertExplanation? explanation,
string? observationCode,
DateTimeOffset triggeredAt,
CancellationToken ct)
{
var explanationJson = explanation is null
? null
: JsonSerializer.Serialize(explanation, JsonOptions);
var affected = observationCode is null
? await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity,
details, explanation, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
{alertType.ToDbString()}, {severity.ToDbString()},
{details}, {explanationJson}::jsonb, '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)
: await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity,
details, explanation, observation_code, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
{alertType.ToDbString()}, {severity.ToDbString()},
{details}, {explanationJson}::jsonb,
{observationCode}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = {alertType.ToDbString()}
AND observation_code = {observationCode}
AND status IN ('OPEN', 'ESCALATED')
)
""", ct);
return affected > 0;
}
}