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