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(); await DbResetHelper.ResetAsync(db); var redis = scope.ServiceProvider.GetRequiredService(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); await detector.ProcessObservationAsync(encounterId, patientId, code, value); } public static async Task WaitForAlertAsync( ApiFixture fixture, Guid encounterId, AlertType alertType) { using var scope = fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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 GetAlertAsync(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>(JsonOptions); return body!.Data!; } public static async Task WaitForFirstAlertWithExplanationAsync( ApiFixture fixture, Guid encounterId) { using var scope = fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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(fixture, alert.Id); } public static async Task 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(); var alert = await db.ClinicalAlerts .AsNoTracking() .Where(a => a.Explanation != null) .OrderByDescending(a => a.TriggeredAt) .FirstAsync(); return await GetAlertAsync(fixture, alert.Id); } public static async Task SeedLegacyAlertWithoutExplanationAsync(ApiFixture fixture) { var (encounterId, patientId) = await SeedEncounterAsync(fixture); using var scope = fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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 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(); 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(); 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().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().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().GetDatabase(); foreach (var key in TrendCalculator.AllHistoryKeys(encounterId)) await cache.KeyDeleteAsync(key); } public record News2ReplayResult(int TotalScore, AlertType AlertType); }