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,169 @@
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
[Collection("Integration")]
public class ExplainableAlertsTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
public ExplainableAlertsTests(ApiFixture fixture) => _fixture = fixture;
public async Task InitializeAsync() =>
await ExplainableAlertsTestHelper.ResetAsync(_fixture);
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task News2Alert_IncludesScoreContributors()
{
var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync(
_fixture, respRate: 28, spo2: 92, systolicBp: 95, heartRate: 110,
tempC: 38.5m, supplementalO2: 1m);
var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync(
_fixture, encounterId, AlertType.News2Emergency);
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
body.Explanation.Should().NotBeNull();
body.Explanation!.ScoreContributors.Should().HaveCount(7);
body.Explanation.ScoreContributors.Should().Contain(c =>
c.Parameter == "Respiratory Rate" && c.Points > 0);
body.Explanation.NarrativeSummary.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task SofaAlert_IncludesOrganContributors()
{
var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedSofaBaselineAsync(_fixture);
await ExplainableAlertsTestHelper.AdvanceSofaDeltaAsync(_fixture, encounterId, patientId, delta: 2);
var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync(
_fixture, encounterId, AlertType.SofaSepsis);
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
body.Explanation.Should().NotBeNull();
body.Explanation!.ScoreContributors.Should().HaveCount(6);
body.Explanation.ScoreContributors.Should().Contain(c => c.Parameter == "Respiratory");
}
[Fact]
public async Task GcsAlert_IncludesEyeVerbalMotor()
{
var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture);
await ExplainableAlertsTestHelper.RecordGcsAsync(
_fixture, encounterId, patientId, eye: 2, verbal: 2, motor: 3);
var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync(
_fixture, encounterId, AlertType.GcsCritical);
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
body.Explanation.Should().NotBeNull();
body.Explanation!.ScoreContributors.Should().HaveCount(3);
body.Explanation.ScoreContributors.Should().Contain(c => c.Parameter == "Eye");
}
[Fact]
public async Task TrendAlert_IncludesTrendContext()
{
var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture);
await ExplainableAlertsTestHelper.SimulateRapidRespRateRiseAsync(
_fixture, encounterId, patientId);
var alert = await ExplainableAlertsTestHelper.WaitForAlertAsync(
_fixture, encounterId, AlertType.RapidDeterioration);
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, alert.Id);
body.Explanation.Should().NotBeNull();
body.Explanation!.Trend.Should().NotBeNull();
body.Explanation.Trend!.PercentChange.Should().BeGreaterThan(0);
body.Explanation.Trend.Duration.Should().NotBe(default);
}
[Fact]
public async Task MedicationAlert_IncludesDrugContext()
{
var (encounterId, patientId) = await ExplainableAlertsTestHelper.SeedEncounterAsync(_fixture);
await ExplainableAlertsTestHelper.AdministerMedicationAsync(
_fixture, encounterId, "acetaminophen", dose: 1000, unit: "mg");
await ExplainableAlertsTestHelper.FeedNews2VitalsAsync(
_fixture, encounterId, patientId,
respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
tempC: 39.2m, supplementalO2: 0m);
var alert = await ExplainableAlertsTestHelper.WaitForFirstAlertWithExplanationAsync(
_fixture, encounterId);
alert.Explanation.Should().NotBeNull();
alert.Explanation!.MedicationContext.Should().NotBeNull();
alert.Explanation.MedicationContext!.DrugName.Should().BeEquivalentTo("acetaminophen");
}
[Fact]
public async Task NarrativeSummary_IsHumanReadable()
{
var alert = await ExplainableAlertsTestHelper.GetAnyAlertWithExplanationAsync(_fixture);
alert.Explanation.Should().NotBeNull();
alert.Explanation!.NarrativeSummary.Should().MatchRegex("(NEWS2|SOFA|GCS|rapid deterioration)", "i");
}
[Fact]
public async Task LegacyAlert_NullExplanation_Serializes()
{
var legacyId = await ExplainableAlertsTestHelper.SeedLegacyAlertWithoutExplanationAsync(_fixture);
var body = await ExplainableAlertsTestHelper.GetAlertAsync<AlertResponse>(_fixture, legacyId);
body.Explanation.Should().BeNull();
}
[Fact]
public async Task ListByEncounter_ReturnsExplanation()
{
var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync(
_fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
tempC: 37.0m, supplementalO2: 0m);
using var client = _fixture.CreateClient();
var response = await client.GetAsync($"/api/v1/encounters/{encounterId}/alerts");
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<ApiResponse<PagedResult<AlertResponse>>>(
ExplainableAlertsTestHelper.JsonOptions);
body!.Data!.Items.Should().NotBeEmpty();
body.Data.Items.Should().AllSatisfy(a =>
{
a.Explanation.Should().NotBeNull();
a.Explanation!.NarrativeSummary.Should().NotBeNullOrWhiteSpace();
});
}
[Fact]
public async Task ScoringOutputs_Unchanged()
{
var before = await ExplainableAlertsTestHelper.RunNews2HighScoreReplayAsync(_fixture);
var after = await ExplainableAlertsTestHelper.RunNews2HighScoreReplayAsync(_fixture);
after.TotalScore.Should().Be(before.TotalScore);
after.AlertType.Should().Be(before.AlertType);
}
[Fact]
public async Task ExplainableAlert_OutboxPayload_IncludesExplanation()
{
var (encounterId, _) = await ExplainableAlertsTestHelper.SeedEncounterWithVitalsAsync(
_fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
tempC: 37.0m, supplementalO2: 0m);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var outbox = await db.OutboxEvents
.Where(e => e.Topic == "alert.generated" && e.PartitionKey == encounterId.ToString())
.OrderByDescending(e => e.CreatedAt)
.FirstAsync();
outbox.Payload.Should().Contain("explanation");
outbox.Payload.Should().Contain("narrativeSummary");
}
}
@@ -0,0 +1,299 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
public static class ExplainableAlertsTestHelper
{
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
private static readonly DateTimeOffset TrendBaseTime =
new(2026, 6, 18, 10, 0, 0, TimeSpan.Zero);
public static async Task ResetAsync(ApiFixture fixture)
{
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
var server = redis.GetServer(redis.GetEndPoints().First());
await server.FlushDatabaseAsync(1);
}
public static async Task<(Guid EncounterId, Guid PatientId)> SeedEncounterAsync(ApiFixture fixture)
{
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var patient = new Patient
{
Id = Guid.NewGuid(), Mrn = $"MRN-EXP-{Guid.NewGuid():N}"[..16],
FirstName = "Explain", LastName = "Test",
DateOfBirth = new DateOnly(1970, 1, 1), Gender = "M",
CreatedAt = DateTimeOffset.UtcNow
};
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = Department.Icu,
AttendingPhysician = "Dr. Explain", AdmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
await db.SaveChangesAsync();
return (encounter.Id, patient.Id);
}
public static async Task FeedNews2VitalsAsync(
ApiFixture fixture, Guid encounterId, Guid patientId,
int respRate, decimal spo2, decimal systolicBp, decimal heartRate,
decimal tempC, decimal supplementalO2, decimal avpu = 0m)
{
await ClearNews2KeysAsync(fixture, encounterId);
using var scope = fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
await detector.ProcessObservationAsync(encounterId, patientId, "RESP_RATE", respRate);
await detector.ProcessObservationAsync(encounterId, patientId, "SPO2", spo2);
await detector.ProcessObservationAsync(encounterId, patientId, "SYSTOLIC_BP", systolicBp);
await detector.ProcessObservationAsync(encounterId, patientId, "HEART_RATE", heartRate);
await detector.ProcessObservationAsync(encounterId, patientId, "AVPU", avpu);
await detector.ProcessObservationAsync(encounterId, patientId, "TEMP_C", tempC);
await detector.ProcessObservationAsync(encounterId, patientId, "SUPPLEMENTAL_O2", supplementalO2);
}
public static async Task<(Guid EncounterId, Guid PatientId)> SeedEncounterWithVitalsAsync(
ApiFixture fixture,
int respRate, decimal spo2, decimal systolicBp, decimal heartRate,
decimal tempC, decimal supplementalO2, decimal avpu = 0m)
{
var (encounterId, patientId) = await SeedEncounterAsync(fixture);
await FeedNews2VitalsAsync(
fixture, encounterId, patientId, respRate, spo2, systolicBp, heartRate,
tempC, supplementalO2, avpu);
return (encounterId, patientId);
}
public static async Task<(Guid EncounterId, Guid PatientId)> SeedSofaBaselineAsync(ApiFixture fixture)
{
var (encounterId, patientId) = await SeedEncounterAsync(fixture);
await SeedSofaBaselineInputsAsync(fixture, encounterId, patientId);
return (encounterId, patientId);
}
public static async Task AdvanceSofaDeltaAsync(
ApiFixture fixture, Guid encounterId, Guid patientId, int delta = 2)
{
using var scope = fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
if (delta >= 2)
{
await detector.ProcessObservationAsync(encounterId, patientId, "PLATELET_K_UL", 20m, DateTimeOffset.UtcNow);
await detector.ProcessObservationAsync(encounterId, patientId, "CREATININE_MG_DL", 4.5m, DateTimeOffset.UtcNow);
}
else
{
await detector.ProcessObservationAsync(encounterId, patientId, "PLATELET_K_UL", 120m, DateTimeOffset.UtcNow);
}
}
public static async Task RecordGcsAsync(
ApiFixture fixture, Guid encounterId, Guid patientId, int eye, int verbal, int motor)
{
using var scope = fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<GcsDetector>();
await ClearGcsKeysAsync(fixture, encounterId);
await detector.ProcessObservationAsync(encounterId, patientId, "GCS_EYE", eye);
await detector.ProcessObservationAsync(encounterId, patientId, "GCS_VERBAL", verbal);
await detector.ProcessObservationAsync(encounterId, patientId, "GCS_MOTOR", motor);
}
public static async Task SimulateRapidRespRateRiseAsync(
ApiFixture fixture, Guid encounterId, Guid patientId)
{
using var scope = fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
await ClearTrendKeysAsync(fixture, encounterId);
await detector.ProcessObservationAsync(
encounterId, patientId, "RESP_RATE", 14m, TrendBaseTime);
await detector.ProcessObservationAsync(
encounterId, patientId, "RESP_RATE", 28m, TrendBaseTime.AddMinutes(10));
}
public static async Task AdministerMedicationAsync(
ApiFixture fixture, Guid encounterId, string drugName, decimal dose, string unit)
{
using var scope = fixture.Services.CreateScope();
var medService = scope.ServiceProvider.GetRequiredService<IMedicationService>();
await medService.CreateAsync(encounterId,
new CreateMedicationAdministrationRequest(
drugName, dose, unit, "PO",
DateTimeOffset.UtcNow.AddMinutes(-30), "nurse-test"));
}
public static async Task RecordVitalAsync(
ApiFixture fixture, Guid encounterId, Guid patientId, string code, decimal value)
{
using var scope = fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
await detector.ProcessObservationAsync(encounterId, patientId, code, value);
}
public static async Task<ClinicalAlert> WaitForAlertAsync(
ApiFixture fixture, Guid encounterId, AlertType alertType)
{
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts
.AsNoTracking()
.Where(a => a.EncounterId == encounterId && a.AlertType == alertType)
.OrderByDescending(a => a.TriggeredAt)
.FirstOrDefaultAsync();
if (alert is null)
throw new InvalidOperationException(
$"Expected {alertType} alert on encounter {encounterId} but none was found.");
return alert;
}
public static async Task<T> GetAlertAsync<T>(ApiFixture fixture, Guid alertId)
{
using var client = fixture.CreateClient();
var response = await client.GetAsync($"/api/v1/alerts/{alertId}");
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<ApiResponse<T>>(JsonOptions);
return body!.Data!;
}
public static async Task<AlertResponse> WaitForFirstAlertWithExplanationAsync(
ApiFixture fixture, Guid encounterId)
{
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts
.AsNoTracking()
.Where(a => a.EncounterId == encounterId && a.Explanation != null)
.OrderByDescending(a => a.TriggeredAt)
.FirstOrDefaultAsync();
if (alert is null)
throw new InvalidOperationException(
$"Expected an alert with explanation on encounter {encounterId}.");
return await GetAlertAsync<AlertResponse>(fixture, alert.Id);
}
public static async Task<AlertResponse> GetAnyAlertWithExplanationAsync(ApiFixture fixture)
{
await SeedEncounterWithVitalsAsync(
fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
tempC: 37.0m, supplementalO2: 0m);
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts
.AsNoTracking()
.Where(a => a.Explanation != null)
.OrderByDescending(a => a.TriggeredAt)
.FirstAsync();
return await GetAlertAsync<AlertResponse>(fixture, alert.Id);
}
public static async Task<Guid> SeedLegacyAlertWithoutExplanationAsync(ApiFixture fixture)
{
var (encounterId, patientId) = await SeedEncounterAsync(fixture);
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alertId = Guid.NewGuid();
db.ClinicalAlerts.Add(new ClinicalAlert
{
Id = alertId,
EncounterId = encounterId,
PatientId = patientId,
AlertType = AlertType.CriticalPotassiumMeqL,
Severity = AlertSeverity.Critical,
Details = "Potassium 2.1 mEq/L is below critical low.",
Status = AlertStatus.Open,
TriggeredAt = DateTimeOffset.UtcNow.AddDays(-1),
Explanation = null
});
await db.SaveChangesAsync();
return alertId;
}
public static async Task<News2ReplayResult> RunNews2HighScoreReplayAsync(ApiFixture fixture)
{
var (encounterId, patientId) = await SeedEncounterWithVitalsAsync(
fixture, respRate: 25, spo2: 91, systolicBp: 95, heartRate: 72,
tempC: 37.0m, supplementalO2: 0m);
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var score = await db.News2Scores
.Where(s => s.EncounterId == encounterId)
.OrderByDescending(s => s.CalculatedAt)
.FirstAsync();
var alert = await db.ClinicalAlerts
.Where(a => a.EncounterId == encounterId)
.OrderByDescending(a => a.TriggeredAt)
.FirstAsync();
return new News2ReplayResult(score.TotalScore, alert.AlertType);
}
private static async Task SeedSofaBaselineInputsAsync(
ApiFixture fixture, Guid encounterId, Guid patientId)
{
using var scope = fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<SofaDetector>();
await detector.ProcessObservationAsync(encounterId, patientId, "PAO2_MMHG", 100m, DateTimeOffset.UtcNow);
await detector.ProcessObservationAsync(encounterId, patientId, "FIO2_PCT", 40m, DateTimeOffset.UtcNow);
await detector.ProcessObservationAsync(encounterId, patientId, "PLATELET_K_UL", 180m, DateTimeOffset.UtcNow);
await detector.ProcessObservationAsync(encounterId, patientId, "BILIRUBIN_MG_DL", 1.0m, DateTimeOffset.UtcNow);
await detector.ProcessObservationAsync(encounterId, patientId, "SYSTOLIC_BP", 120m, DateTimeOffset.UtcNow);
await detector.ProcessObservationAsync(encounterId, patientId, "DIASTOLIC_BP", 80m, DateTimeOffset.UtcNow);
await detector.ProcessObservationAsync(encounterId, patientId, "CREATININE_MG_DL", 1.0m, DateTimeOffset.UtcNow);
await detector.ProcessObservationAsync(encounterId, patientId, "URINE_OUTPUT_ML_H", 50m, DateTimeOffset.UtcNow);
}
private static async Task ClearNews2KeysAsync(ApiFixture fixture, Guid encounterId)
{
using var scope = fixture.Services.CreateScope();
var cache = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>().GetDatabase();
foreach (var key in News2Calculator.AllParameterKeys(encounterId))
await cache.KeyDeleteAsync(key);
}
private static async Task ClearGcsKeysAsync(ApiFixture fixture, Guid encounterId)
{
using var scope = fixture.Services.CreateScope();
var cache = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>().GetDatabase();
foreach (var key in GcsCalculator.AllComponentKeys(encounterId))
await cache.KeyDeleteAsync(key);
}
private static async Task ClearTrendKeysAsync(ApiFixture fixture, Guid encounterId)
{
using var scope = fixture.Services.CreateScope();
var cache = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>().GetDatabase();
foreach (var key in TrendCalculator.AllHistoryKeys(encounterId))
await cache.KeyDeleteAsync(key);
}
public record News2ReplayResult(int TotalScore, AlertType AlertType);
}