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,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);
}