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;
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
+1870
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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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