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;
}
}
@@ -24,7 +24,7 @@ public class AlertsController : ControllerBase
/// <returns>A paginated list of alerts for the encounter.</returns>
[HttpGet("api/v1/encounters/{encounterId:guid}/alerts")]
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<AlertResponse>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ListByEncounter(
Guid encounterId,
@@ -46,14 +46,7 @@ public class AlertsController : ControllerBase
}
var result = await _alerts.ListByEncounterAsync(encounterId, parsedStatus, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
return Ok(ApiResponse<PagedResult<AlertResponse>>.Ok(result));
}
/// <summary>
@@ -67,7 +60,7 @@ public class AlertsController : ControllerBase
/// <returns>A paginated list of alerts.</returns>
[HttpGet("api/v1/alerts")]
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PagedResult<AlertResponse>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ListGlobal(
[FromQuery] string? status,
@@ -116,14 +109,7 @@ public class AlertsController : ControllerBase
}
var result = await _alerts.ListGlobalAsync(parsedStatus, parsedSeverity, parsedDepartment, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
return Ok(ApiResponse<PagedResult<AlertResponse>>.Ok(result));
}
/// <summary>
@@ -133,12 +119,12 @@ public class AlertsController : ControllerBase
/// <returns>The alert record.</returns>
[HttpGet("api/v1/alerts/{id:guid}")]
[AuthorizePermission(ClinicalPermissions.AlertsRead)]
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<AlertResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id)
{
var alert = await _alerts.GetByIdAsync(id);
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
return Ok(ApiResponse<AlertResponse>.Ok(alert));
}
/// <summary>
@@ -1,8 +1,14 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert>
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public void Configure(EntityTypeBuilder<ClinicalAlert> builder)
{
builder.ToTable("clinical_alerts", t =>
@@ -68,6 +74,12 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
builder.Property(a => a.FeedbackReceived).HasColumnName("feedback_received")
.HasDefaultValue(false);
builder.Property(a => a.Explanation)
.HasColumnName("explanation")
.HasColumnType("jsonb")
.HasConversion(
v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions),
v => string.IsNullOrEmpty(v) ? null : JsonSerializer.Deserialize<AlertExplanation>(v, JsonOptions)!);
builder.HasOne(a => a.Encounter)
.WithMany(e => e.Alerts)
@@ -16,6 +16,7 @@ public class ClinicalAlert
public Guid? ClientAlertId { get; set; }
public bool SyncedFromGateway { get; set; }
public bool FeedbackReceived { get; set; }
public AlertExplanation? Explanation { get; set; }
public Encounter Encounter { get; set; } = null!;
public List<AlertFeedback> Feedbacks { get; set; } = new();
@@ -0,0 +1,7 @@
public class AlertExplanation
{
public List<ScoreContributor> ScoreContributors { get; set; } = new();
public TrendContext? Trend { get; set; }
public MedicationContext? MedicationContext { get; set; }
public string NarrativeSummary { get; set; } = string.Empty;
}
@@ -0,0 +1,8 @@
public class MedicationContext
{
public string DrugName { get; set; } = null!;
public string Dose { get; set; } = null!;
public string Route { get; set; } = null!;
public DateTimeOffset AdministeredAt { get; set; }
public string? RelevanceNote { get; set; }
}
@@ -0,0 +1,7 @@
public class ScoreContributor
{
public string Parameter { get; set; } = null!;
public int Points { get; set; }
public string? RawValue { get; set; }
public string? NormalRange { get; set; }
}
@@ -0,0 +1,7 @@
public class TrendContext
{
public string Parameter { get; set; } = null!;
public double PercentChange { get; set; }
public TimeSpan Duration { get; set; }
public string Direction { get; set; } = null!;
}
@@ -0,0 +1,9 @@
public static class GcsContributorBuilder
{
public static List<ScoreContributor> Build(int eye, int verbal, int motor) => new()
{
new() { Parameter = "Eye", Points = eye, RawValue = eye.ToString(), NormalRange = "4" },
new() { Parameter = "Verbal", Points = verbal, RawValue = verbal.ToString(), NormalRange = "5" },
new() { Parameter = "Motor", Points = motor, RawValue = motor.ToString(), NormalRange = "6" },
};
}
+17 -17
View File
@@ -75,18 +75,20 @@ public class GcsDetector
encounterId, patientId, (int)eye, (int)verbal, (int)motor,
total, classification, calculatedAt, ct);
var contributors = GcsContributorBuilder.Build((int)eye, (int)verbal, (int)motor);
var alertCreated = false;
if (total <= 8)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.GcsCritical, AlertSeverity.Critical,
eye, verbal, motor, total, classification, ct);
eye, verbal, motor, total, classification, contributors, ct);
}
else if (total <= 12)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.GcsWarning, AlertSeverity.Warning,
eye, verbal, motor, total, classification, ct);
eye, verbal, motor, total, classification, contributors, ct);
}
await PublishScoredEventAsync(
@@ -105,7 +107,8 @@ public class GcsDetector
"GCS score {Total} ({Classification}) for encounter {Id} — E={Eye} V={Verbal} M={Motor}",
total, classification, encounterId, eye, verbal, motor);
return new GcsResult(GcsOutcome.ScoreComputed, total, classification, alertCreated, 3);
return new GcsResult(
GcsOutcome.ScoreComputed, total, classification, alertCreated, 3, contributors);
}
private async Task PersistScoreAsync(
@@ -140,6 +143,7 @@ public class GcsDetector
AlertType alertType, AlertSeverity severity,
decimal eye, decimal verbal, decimal motor,
int total, string classification,
List<ScoreContributor> contributors,
CancellationToken ct)
{
if (alertType == AlertType.GcsWarning)
@@ -164,20 +168,15 @@ public class GcsDetector
var details =
$"GCS total {total} ({classification}): E={eye}, V={verbal}, M={motor}.";
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, '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);
var explanation = AlertExplanationBuilder.Build(
"GCS", total, contributors);
if (affected == 0)
var inserted = await ClinicalAlertFactory.TryInsertAsync(
db, alertId, encounterId, patientId,
alertType, severity, details, explanation,
observationCode: null, triggeredAt, ct);
if (!inserted)
{
await tx.RollbackAsync(ct);
return false;
@@ -187,7 +186,7 @@ public class GcsDetector
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
Payload = ClinicalAlertFactory.SerializeOutboxPayload(new
{
alertId,
encounterId,
@@ -198,6 +197,7 @@ public class GcsDetector
triggeredAt,
gcsTotal = total,
gcsClassification = classification,
explanation,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
@@ -36,4 +36,45 @@ public class MedicationCorrelationHelper
return $"{details} — note: {med.DrugName} {med.Dose}{med.DoseUnit} " +
$"({med.Route}) administered {minutesAgo} min ago";
}
public async Task<MedicationContext?> TryGetContextAsync(
Guid encounterId,
string observationCode,
CancellationToken ct = default)
{
var recent = await _medicationService.GetRecentForEncounterAsync(
encounterId, observationCode, _options.CorrelationWindowMinutes);
if (recent.Count == 0)
return null;
var med = recent[0];
return new MedicationContext
{
DrugName = med.DrugName,
Dose = $"{med.Dose}{med.DoseUnit}",
Route = med.Route,
AdministeredAt = med.AdministeredAt,
RelevanceNote = BuildRelevanceNote(med.DrugName, observationCode)
};
}
private string? BuildRelevanceNote(string drugName, string observationCode)
{
var drug = drugName.ToLowerInvariant();
return observationCode switch
{
"TEMP_C" when drug is "acetaminophen" or "ibuprofen"
=> "Antipyretic — may affect temperature trend",
"HEART_RATE" or "SYSTOLIC_BP" or "DIASTOLIC_BP"
when _options.DrugVitalMappings.TryGetValue(drug, out var codes)
&& codes.Contains(observationCode, StringComparer.OrdinalIgnoreCase)
=> "Cardiovascular agent — may affect blood pressure or heart rate",
"RESP_RATE" or "SPO2"
=> "Sedative/opioid — may affect respiratory parameters",
"GLUCOSE_MG_DL"
=> "Glucose management — may affect blood sugar",
_ => null
};
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddAlertExplanation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "explanation",
table: "clinical_alerts",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "explanation",
table: "clinical_alerts");
}
}
}
@@ -256,6 +256,10 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("Explanation")
.HasColumnType("jsonb")
.HasColumnName("explanation");
b.Property<bool>("FeedbackReceived")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -1,3 +1,4 @@
public record AlertGeneratedEvent(
Guid AlertId, Guid EncounterId, Guid PatientId, string? Department,
string AlertType, string Severity, DateTimeOffset TriggeredAt);
string AlertType, string Severity, DateTimeOffset TriggeredAt,
AlertExplanation? Explanation = null);
@@ -0,0 +1,13 @@
public record AlertResponse(
Guid Id,
Guid EncounterId,
Guid PatientId,
AlertType AlertType,
AlertSeverity Severity,
string Details,
AlertStatus Status,
DateTimeOffset TriggeredAt,
DateTimeOffset? AcknowledgedAt,
string? AcknowledgedBy,
DateTimeOffset? ResolvedAt,
AlertExplanation? Explanation);
@@ -3,7 +3,8 @@ public record GcsResult(
int? TotalScore = null,
string? Classification = null,
bool AlertCreated = false,
int PresentComponents = 0)
int PresentComponents = 0,
List<ScoreContributor>? Contributors = null)
{
public static readonly GcsResult NotGcsCode = new(GcsOutcome.NotGcsCode);
public static readonly GcsResult EncounterNotFound = new(GcsOutcome.EncounterNotFound);
@@ -4,7 +4,8 @@ public record News2Result(
string? RiskLevel = null,
bool AlertCreated = false,
int PresentParameters = 0,
bool HasSingleParamThree = false)
bool HasSingleParamThree = false,
List<ScoreContributor>? Contributors = null)
{
public static readonly News2Result NotNews2Code = new(News2Outcome.NotNews2Code);
public static readonly News2Result EncounterNotFound = new(News2Outcome.EncounterNotFound);
@@ -3,7 +3,8 @@ public record SofaScoringResult(
SofaResult? Score = null,
bool IsBaseline = false,
int? DeltaFromBaseline = null,
bool AlertCreated = false)
bool AlertCreated = false,
List<ScoreContributor>? Contributors = null)
{
public static readonly SofaScoringResult NotSofaTrigger = new(SofaOutcome.NotSofaTrigger);
public static readonly SofaScoringResult EncounterNotFound = new(SofaOutcome.EncounterNotFound);
@@ -2,4 +2,5 @@ public record TrendResult(
TrendOutcome Outcome,
string? ObservationCode = null,
decimal? RatePerMinute = null,
bool AlertCreated = false);
bool AlertCreated = false,
TrendContext? Trend = null);
@@ -0,0 +1,31 @@
public static class News2ContributorBuilder
{
private static readonly Dictionary<string, (string Label, string NormalRange)> Labels = new()
{
["RESP_RATE"] = ("Respiratory Rate", "12-20 breaths/min"),
["SPO2"] = ("SpO2", "96-100%"),
["SYSTOLIC_BP"] = ("Systolic BP", "101-219 mmHg"),
["HEART_RATE"] = ("Heart Rate", "51-90 bpm"),
["AVPU"] = ("Consciousness", "Alert"),
["TEMP_C"] = ("Temperature", "36.1-38.0 °C"),
["SUPPLEMENTAL_O2"] = ("Supplemental O2", "Room air"),
};
public static List<ScoreContributor> Build(int[] paramScores, string?[]? rawValues = null)
{
var contributors = new List<ScoreContributor>();
for (int i = 0; i < News2Calculator.ParameterCodes.Count; i++)
{
var code = News2Calculator.ParameterCodes[i];
var (label, range) = Labels[code];
contributors.Add(new ScoreContributor
{
Parameter = label,
Points = paramScores[i],
RawValue = rawValues?[i],
NormalRange = range
});
}
return contributors;
}
}
+50 -25
View File
@@ -69,11 +69,14 @@ public class News2Detector
var allValues = await cache.StringGetAsync(allKeys);
var scores = new int?[7];
var rawValues = new string?[7];
for (int i = 0; i < 7; i++)
{
if (i == 4) // consciousness — GCS-first, AVPU-fallback
{
scores[4] = await ResolveConsciousnessScoreAsync(encounterId);
rawValues[4] = await ResolveConsciousnessRawValueAsync(encounterId);
if (scores[4] is null)
{
var present = allValues.Count(v => v.HasValue) + 0;
@@ -96,9 +99,11 @@ public class News2Detector
var cached = JsonSerializer.Deserialize<News2CachedParam>(allValues[i]!, CachedParamJsonOptions);
scores[i] = cached?.Score;
rawValues[i] = cached?.Value.ToString();
}
var paramScores = scores.Select(s => s!.Value).ToArray();
var contributors = News2ContributorBuilder.Build(paramScores, rawValues);
var totalScore = paramScores.Sum();
var hasSingleParamThree = paramScores.Any(s => s == 3);
var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree);
@@ -123,17 +128,17 @@ public class News2Detector
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.News2Emergency, AlertSeverity.Critical,
totalScore, riskLevel, paramScores, ct);
totalScore, riskLevel, paramScores, contributors, ct);
}
else if (riskLevel == "MEDIUM" || riskLevel == "LOW_MEDIUM")
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.News2Warning, AlertSeverity.Warning,
totalScore, riskLevel, paramScores, ct);
totalScore, riskLevel, paramScores, contributors, ct);
}
return new News2Result(
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree, contributors);
}
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
@@ -159,6 +164,28 @@ public class News2Detector
return cached?.Score;
}
private async Task<string?> ResolveConsciousnessRawValueAsync(Guid encounterId)
{
var cache = _redis.GetDatabase();
var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
if (gcsValues.All(v => v.HasValue))
{
var eye = decimal.Parse(gcsValues[0]!);
var verbal = decimal.Parse(gcsValues[1]!);
var motor = decimal.Parse(gcsValues[2]!);
return GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value.ToString();
}
var avpuVal = await cache.StringGetAsync(
News2Calculator.ParameterKey(encounterId, "AVPU"));
if (!avpuVal.HasValue)
return null;
var cached = JsonSerializer.Deserialize<News2CachedParam>(avpuVal!, CachedParamJsonOptions);
return cached?.Value.ToString();
}
private async Task<Guid> PersistScoreAsync(
Guid encounterId, Guid patientId,
int totalScore, string riskLevel,
@@ -198,6 +225,7 @@ public class News2Detector
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
int totalScore, string riskLevel, int[] paramScores,
List<ScoreContributor> contributors,
CancellationToken ct)
{
if (alertType == AlertType.News2Warning)
@@ -222,31 +250,26 @@ public class News2Detector
var details = BuildDetails(totalScore, riskLevel, paramScores);
var correlation = scope.ServiceProvider.GetRequiredService<MedicationCorrelationHelper>();
var annotatedParts = new List<string>();
foreach (var code in News2Calculator.ParameterCodes)
MedicationContext? medication = null;
foreach (var (code, score) in News2Calculator.ParameterCodes.Zip(paramScores))
{
var part = await correlation.TryAnnotateDetailsAsync(
encounterId, code, "", ct);
if (part.StartsWith(" — note:"))
annotatedParts.Add(part.TrimStart(' ', '—').Trim());
if (score <= 0)
continue;
medication = await correlation.TryGetContextAsync(encounterId, code, ct);
if (medication is not null)
break;
}
if (annotatedParts.Count > 0)
details += " — " + string.Join("; ", annotatedParts.Distinct());
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, '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);
var explanation = AlertExplanationBuilder.Build(
"NEWS2", totalScore, contributors, medication: medication);
if (affected == 0)
var inserted = await ClinicalAlertFactory.TryInsertAsync(
db, alertId, encounterId, patientId,
alertType, severity, details, explanation,
observationCode: null, triggeredAt, ct);
if (!inserted)
{
await tx.RollbackAsync(ct);
return false;
@@ -256,16 +279,18 @@ public class News2Detector
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
Payload = ClinicalAlertFactory.SerializeOutboxPayload(new
{
alertId,
encounterId,
patientId,
alertType = alertType.ToDbString(),
severity = severity.ToDbString(),
details,
triggeredAt,
news2Score = totalScore,
news2RiskLevel = riskLevel,
explanation,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
+27 -6
View File
@@ -21,7 +21,7 @@ public class AlertService : IAlertService
_audit = audit;
}
public async Task<PagedResult<ClinicalAlert>> ListByEncounterAsync(
public async Task<PagedResult<AlertResponse>> ListByEncounterAsync(
Guid encounterId, AlertStatus? status, int page, int pageSize)
{
var query = _db.ClinicalAlerts
@@ -38,10 +38,12 @@ public class AlertService : IAlertService
.Take(pageSize)
.ToListAsync();
return new PagedResult<ClinicalAlert>(alerts, page, pageSize, total);
return new PagedResult<AlertResponse>(
alerts.Select(AlertResponseMapper.ToResponse).ToList(),
page, pageSize, total);
}
public async Task<PagedResult<ClinicalAlert>> ListGlobalAsync(
public async Task<PagedResult<AlertResponse>> ListGlobalAsync(
AlertStatus? status, AlertSeverity? severity, Department? department, int page, int pageSize)
{
var query = _db.ClinicalAlerts
@@ -65,10 +67,12 @@ public class AlertService : IAlertService
.Take(pageSize)
.ToListAsync();
return new PagedResult<ClinicalAlert>(alerts, page, pageSize, total);
return new PagedResult<AlertResponse>(
alerts.Select(AlertResponseMapper.ToResponse).ToList(),
page, pageSize, total);
}
public async Task<ClinicalAlert> GetByIdAsync(Guid id)
public async Task<AlertResponse> GetByIdAsync(Guid id)
{
var alert = await _db.ClinicalAlerts
.AsNoTracking()
@@ -78,7 +82,7 @@ public class AlertService : IAlertService
if (alert is null)
throw new NotFoundException("Alert not found.", "ALERT_NOT_FOUND");
return alert;
return AlertResponseMapper.ToResponse(alert);
}
public async Task<ClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req)
@@ -304,4 +308,21 @@ public class AlertService : IAlertService
var prefix = $"[{roleLabel}] Acknowledged by {displayName}.";
return string.IsNullOrWhiteSpace(note) ? prefix : $"{prefix} {note.Trim()}";
}
public static class AlertResponseMapper
{
public static AlertResponse ToResponse(ClinicalAlert alert) => new(
alert.Id,
alert.EncounterId,
alert.PatientId,
alert.AlertType,
alert.Severity,
alert.Details,
alert.Status,
alert.TriggeredAt,
alert.AcknowledgedAt,
alert.AcknowledgedBy,
alert.ResolvedAt,
alert.Explanation);
}
}
@@ -1,12 +1,12 @@
public interface IAlertService
{
Task<PagedResult<ClinicalAlert>> ListByEncounterAsync(
Task<PagedResult<AlertResponse>> ListByEncounterAsync(
Guid encounterId, AlertStatus? status, int page, int pageSize);
Task<PagedResult<ClinicalAlert>> ListGlobalAsync(
Task<PagedResult<AlertResponse>> ListGlobalAsync(
AlertStatus? status, AlertSeverity? severity, Department? department, int page, int pageSize);
Task<ClinicalAlert> GetByIdAsync(Guid id);
Task<AlertResponse> GetByIdAsync(Guid id);
Task<ClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req);
@@ -0,0 +1,41 @@
public static class SofaContributorBuilder
{
private static readonly (string Label, string NormalRange)[] OrganSystems =
[
("Respiratory", "0"),
("Coagulation", "0"),
("Liver", "0"),
("Cardiovascular", "0"),
("CNS", "0"),
("Renal", "0"),
];
private static readonly string[] OrganKeys =
[
"RESPIRATORY", "COAGULATION", "LIVER", "CARDIOVASCULAR", "CNS", "RENAL"
];
public static List<ScoreContributor> Build(
SofaResult result,
IReadOnlyDictionary<string, string?>? rawValues = null)
{
var scores = new[]
{
result.Respiratory, result.Coagulation, result.Liver,
result.Cardiovascular, result.Cns, result.Renal
};
var contributors = new List<ScoreContributor>(scores.Length);
for (int i = 0; i < scores.Length; i++)
{
contributors.Add(new ScoreContributor
{
Parameter = OrganSystems[i].Label,
Points = scores[i],
RawValue = rawValues?.GetValueOrDefault(OrganKeys[i]),
NormalRange = OrganSystems[i].NormalRange
});
}
return contributors;
}
}
+31 -17
View File
@@ -147,6 +147,23 @@ public class SofaDetector
var result = SofaCalculator.ComputeTotal(
respiratory, coagulation, liver, cardiovascular, cns, renal);
var rawValues = new Dictionary<string, string?>
{
["RESPIRATORY"] = pao2 is not null && fio2 is not null
? $"PaO2/FiO2={pao2}/{fio2}%"
: usedSpO2Fallback && spo2 is not null && fio2 is not null
? $"SpO2/FiO2={spo2}/{fio2}%"
: null,
["COAGULATION"] = GetValue("PLATELET_K_UL")?.ToString(),
["LIVER"] = GetValue("BILIRUBIN_MG_DL")?.ToString(),
["CARDIOVASCULAR"] = vasopressor is not null
? $"{vasopressor.DrugName} {vasopressor.DoseUgKgMin} µg/kg/min"
: map?.ToString(),
["CNS"] = gcsTotal?.ToString(),
["RENAL"] = creatinine?.ToString() ?? urineMlDay?.ToString()
};
var contributors = SofaContributorBuilder.Build(result, rawValues);
var stalenessFlags = JsonSerializer.Serialize(new SofaStalenessFlags(
staleComponents.Distinct().ToList(),
missingComponents.Distinct().ToList(),
@@ -179,13 +196,13 @@ public class SofaDetector
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.SofaSepsis, AlertSeverity.Critical,
result, isBaseline ? null : delta, stalenessFlags, ct);
result, isBaseline ? null : delta, stalenessFlags, contributors, ct);
}
else if (delta == 1)
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.SofaWarning, AlertSeverity.Warning,
result, delta, stalenessFlags, ct);
result, delta, stalenessFlags, contributors, ct);
}
_metrics.SofaScoresTotal
@@ -197,7 +214,7 @@ public class SofaDetector
result.Total, encounterId, isBaseline, delta);
return new SofaScoringResult(
SofaOutcome.ScoreComputed, result, isBaseline, delta, alertCreated);
SofaOutcome.ScoreComputed, result, isBaseline, delta, alertCreated, contributors);
}
private async Task<int?> LoadGcsTotalAsync(Guid encounterId)
@@ -268,6 +285,7 @@ public class SofaDetector
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
SofaResult result, int? delta, string stalenessFlags,
List<ScoreContributor> contributors,
CancellationToken ct)
{
if (alertType == AlertType.SofaWarning)
@@ -292,20 +310,15 @@ public class SofaDetector
$"Liver={result.Liver}, CV={result.Cardiovascular}, CNS={result.Cns}, Renal={result.Renal}. " +
$"Staleness: {stalenessFlags}";
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, '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);
var explanation = AlertExplanationBuilder.Build(
"SOFA", result.Total, contributors);
if (affected == 0)
var inserted = await ClinicalAlertFactory.TryInsertAsync(
db, alertId, encounterId, patientId,
alertType, severity, details, explanation,
observationCode: null, triggeredAt, ct);
if (!inserted)
{
await tx.RollbackAsync(ct);
return false;
@@ -315,7 +328,7 @@ public class SofaDetector
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
Payload = ClinicalAlertFactory.SerializeOutboxPayload(new
{
alertId,
encounterId,
@@ -326,6 +339,7 @@ public class SofaDetector
triggeredAt,
sofaTotal = result.Total,
sofaDelta = delta,
explanation,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
@@ -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(),