From bf46e6554a7bea08773a658e1ab31524498f63f3 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sun, 21 Jun 2026 03:56:27 +0800 Subject: [PATCH] feature: Sepsis Engine Refactor: Remove SIRS, Rewire qSOFA + Bundle Frontend: GCS Entry, SOFA Display, Sepsis UI Refactor --- .../AlertCreationGuardTests.cs | 19 + .../ObservabilityPhase8Tests.cs | 2 +- .../QsofaDetectorTests.cs | 8 +- .../SepsisBundleTests.cs | 92 +- .../SepsisRefactorTests.cs | 191 +++ .../SirsDetectorTests.cs | 213 ---- .../SirsEvaluatorTests.cs | 30 - .../BackgroundServices/SepsisEngineService.cs | 30 +- .../ClinicalAlertConfiguration.cs | 9 +- .../Domains/Enums/AlertType.cs | 8 +- VigilCareClinicalAPI/Gcs/GcsDetector.cs | 2 + ...432_AddQsofaScreenAlertType.cs.Designer.cs | 1085 +++++++++++++++++ ...260620191432_AddQsofaScreenAlertType.cs.cs | 44 + .../Migrations/AppDbContextModelSnapshot.cs | 2 +- .../Models/Records/Sepsis/SirsOutcome.cs | 7 - .../Models/Records/Sepsis/SirsResult.cs | 11 - VigilCareClinicalAPI/News2/News2Detector.cs | 2 + .../Observability/Metrics/ClinicalMetrics.cs | 11 +- VigilCareClinicalAPI/Program.cs | 1 - .../Sepsis/AlertCreationGuard.cs | 9 + .../Sepsis/QsofaCalculator.cs | 4 + VigilCareClinicalAPI/Sepsis/QsofaDetector.cs | 71 +- .../Sepsis/SepsisAlertHandler.cs | 10 +- VigilCareClinicalAPI/Sepsis/SirsDetector.cs | 177 --- VigilCareClinicalAPI/Sepsis/SirsEvaluator.cs | 38 - .../Services/SepsisBundleService.cs | 4 + VigilCareClinicalAPI/Sofa/SofaDetector.cs | 8 + docs/decisions/sepsis-engine-design.md | 20 +- scripts/run-phase27-verification.sh | 28 + .../src/__tests__/AlertCard.test.js | 2 +- .../src/__tests__/AlertLabels.test.js | 28 + .../src/__tests__/AlertReasoning.test.js | 24 +- .../src/__tests__/FeedbackSummary.test.js | 4 +- .../src/__tests__/GcsEntryForm.test.js | 63 + .../src/__tests__/ScoresPanel.test.js | 49 + .../src/__tests__/SofaScorePanel.test.js | 110 ++ vigilcare-dashboard/src/api/clinical.js | 37 + vigilcare-dashboard/src/api/normalize.js | 60 +- .../src/components/alerts/AlertCard.vue | 18 +- .../src/components/alerts/AlertReasoning.vue | 110 +- .../src/components/patient/GcsEntryForm.vue | 107 ++ .../src/components/patient/ScoresPanel.vue | 157 ++- .../components/patient/SepsisBundlePanel.vue | 105 +- .../src/components/patient/SofaScorePanel.vue | 78 ++ vigilcare-dashboard/src/composables/useGcs.js | 87 ++ .../src/composables/useSofa.js | 67 + vigilcare-dashboard/src/stores/scoring.js | 130 ++ .../src/views/PatientDetail.vue | 28 +- 48 files changed, 2686 insertions(+), 714 deletions(-) create mode 100644 VigilCareClinicalAPI.Tests/AlertCreationGuardTests.cs create mode 100644 VigilCareClinicalAPI.Tests/SepsisRefactorTests.cs delete mode 100644 VigilCareClinicalAPI.Tests/SirsDetectorTests.cs delete mode 100644 VigilCareClinicalAPI.Tests/SirsEvaluatorTests.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260620191432_AddQsofaScreenAlertType.cs.Designer.cs create mode 100644 VigilCareClinicalAPI/Migrations/20260620191432_AddQsofaScreenAlertType.cs.cs delete mode 100644 VigilCareClinicalAPI/Models/Records/Sepsis/SirsOutcome.cs delete mode 100644 VigilCareClinicalAPI/Models/Records/Sepsis/SirsResult.cs create mode 100644 VigilCareClinicalAPI/Sepsis/AlertCreationGuard.cs delete mode 100644 VigilCareClinicalAPI/Sepsis/SirsDetector.cs delete mode 100644 VigilCareClinicalAPI/Sepsis/SirsEvaluator.cs create mode 100755 scripts/run-phase27-verification.sh create mode 100644 vigilcare-dashboard/src/__tests__/AlertLabels.test.js create mode 100644 vigilcare-dashboard/src/__tests__/GcsEntryForm.test.js create mode 100644 vigilcare-dashboard/src/__tests__/ScoresPanel.test.js create mode 100644 vigilcare-dashboard/src/__tests__/SofaScorePanel.test.js create mode 100644 vigilcare-dashboard/src/components/patient/GcsEntryForm.vue create mode 100644 vigilcare-dashboard/src/components/patient/SofaScorePanel.vue create mode 100644 vigilcare-dashboard/src/composables/useGcs.js create mode 100644 vigilcare-dashboard/src/composables/useSofa.js create mode 100644 vigilcare-dashboard/src/stores/scoring.js diff --git a/VigilCareClinicalAPI.Tests/AlertCreationGuardTests.cs b/VigilCareClinicalAPI.Tests/AlertCreationGuardTests.cs new file mode 100644 index 0000000..f287f9f --- /dev/null +++ b/VigilCareClinicalAPI.Tests/AlertCreationGuardTests.cs @@ -0,0 +1,19 @@ +using FluentAssertions; + +public class AlertCreationGuardTests +{ + [Fact] + public void CannotCreateNewSepsisWarning() + { + var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning); + act.Should().Throw() + .WithMessage("*deprecated*"); + } + + [Fact] + public void AllowsSofaSepsis() + { + var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SofaSepsis); + act.Should().NotThrow(); + } +} diff --git a/VigilCareClinicalAPI.Tests/ObservabilityPhase8Tests.cs b/VigilCareClinicalAPI.Tests/ObservabilityPhase8Tests.cs index 3efee2a..52c8df7 100644 --- a/VigilCareClinicalAPI.Tests/ObservabilityPhase8Tests.cs +++ b/VigilCareClinicalAPI.Tests/ObservabilityPhase8Tests.cs @@ -15,7 +15,7 @@ public class ObservabilityPhase8Tests "alerts_unacknowledged_gauge", "kafka_consumer_lag", "outbox_pending_events", - "sirs_detections_total", + "qsofa_detections_total", "escalations_total", }; diff --git a/VigilCareClinicalAPI.Tests/QsofaDetectorTests.cs b/VigilCareClinicalAPI.Tests/QsofaDetectorTests.cs index 231ee10..b8307ba 100644 --- a/VigilCareClinicalAPI.Tests/QsofaDetectorTests.cs +++ b/VigilCareClinicalAPI.Tests/QsofaDetectorTests.cs @@ -80,12 +80,12 @@ public class QsofaDetectorTests : IAsyncLifetime r2.Outcome.Should().Be(QsofaOutcome.AlertCreated); var alert = await db.ClinicalAlerts.SingleAsync(); - alert.AlertType.Should().Be(AlertType.QsofaWarning); - alert.Severity.Should().Be(AlertSeverity.Critical); + alert.AlertType.Should().Be(AlertType.QsofaScreen); + alert.Severity.Should().Be(AlertSeverity.Warning); alert.Status.Should().Be(AlertStatus.Open); alert.EncounterId.Should().Be(_encounterId); alert.PatientId.Should().Be(_patientId); - alert.Details.Should().Contain("qSOFA score 2/3"); + alert.Details.Should().Contain("order SOFA labs"); var outbox = await db.OutboxEvents.SingleAsync(e => e.Topic == "alert.generated"); outbox.PartitionKey.Should().Be(_encounterId.ToString()); @@ -138,7 +138,7 @@ public class QsofaDetectorTests : IAsyncLifetime r3.Outcome.Should().Be(QsofaOutcome.AlertAlreadyOpen); var alertCount = await db.ClinicalAlerts.CountAsync(); - alertCount.Should().Be(1, "WHERE NOT EXISTS prevents duplicate QSOFA_WARNING while one is open"); + alertCount.Should().Be(1, "WHERE NOT EXISTS prevents duplicate QSOFA_SCREEN while one is open"); var outboxCount = await db.OutboxEvents.CountAsync(e => e.Topic == "alert.generated"); outboxCount.Should().Be(1, "outbox event must be written exactly once"); diff --git a/VigilCareClinicalAPI.Tests/SepsisBundleTests.cs b/VigilCareClinicalAPI.Tests/SepsisBundleTests.cs index 72df317..c0592ed 100644 --- a/VigilCareClinicalAPI.Tests/SepsisBundleTests.cs +++ b/VigilCareClinicalAPI.Tests/SepsisBundleTests.cs @@ -43,28 +43,47 @@ public class SepsisBundleTests : IAsyncLifetime var cache = redis.GetDatabase(); foreach (var key in QsofaCalculator.AllCriterionKeys(_encounterId)) await cache.KeyDeleteAsync(key); - foreach (var key in SirsEvaluator.AllCriterionKeys(_encounterId)) - await cache.KeyDeleteAsync(key); } public Task DisposeAsync() => Task.CompletedTask; - [Fact] - public async Task QsofaAlert_CreatesBundleWithFourOrders() + private async Task CreateSofaSepsisBundleAsync(IServiceScope scope) { - using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); + var handler = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); - await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + var alertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = alertId, + EncounterId = _encounterId, + PatientId = _patientId, + AlertType = AlertType.SofaSepsis, + Severity = AlertSeverity.Critical, + Details = "SOFA delta +2", + Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + await handler.OnSepsisAlertCreatedAsync( + _encounterId, alertId, AlertType.SofaSepsis, CancellationToken.None); + } + + [Fact] + public async Task SofaSepsisAlert_CreatesBundleWithFourOrders() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await CreateSofaSepsisBundleAsync(scope); var bundle = await db.SepsisBundles .Include(b => b.Elements) .SingleAsync(b => b.EncounterId == _encounterId); bundle.ComplianceStatus.Should().Be(SepsisBundleComplianceStatus.InProgress); - bundle.TriggeringAlertType.Should().Be("QSOFA_WARNING"); + bundle.TriggeringAlertType.Should().Be("SOFA_SEPSIS"); bundle.DeadlineAt.Should().BeCloseTo(bundle.RecognizedAt.AddHours(1), TimeSpan.FromSeconds(5)); bundle.Elements.Should().HaveCount(4); @@ -86,40 +105,45 @@ public class SepsisBundleTests : IAsyncLifetime } [Fact] - public async Task SirsAlert_CreatesBundle() + public async Task QsofaScreen_DoesNotTriggerBundle() { using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); + var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); - await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); + await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); + await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); - var bundle = await db.SepsisBundles - .Include(b => b.Elements) - .SingleAsync(b => b.EncounterId == _encounterId); - - bundle.TriggeringAlertType.Should().Be("SEPSIS_WARNING"); - bundle.ComplianceStatus.Should().Be(SepsisBundleComplianceStatus.InProgress); - bundle.Elements.Should().HaveCount(4); + (await db.SepsisBundles.CountAsync()).Should().Be(0); } [Fact] public async Task SecondAlert_IdempotentBundle() { using var scope = _fixture.Services.CreateScope(); - var qsofaDetector = scope.ServiceProvider.GetRequiredService(); - var sirsDetector = scope.ServiceProvider.GetRequiredService(); + var handler = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); - await qsofaDetector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); - await qsofaDetector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + await CreateSofaSepsisBundleAsync(scope); var bundleCountAfterFirst = await db.SepsisBundles.CountAsync(b => b.EncounterId == _encounterId); bundleCountAfterFirst.Should().Be(1); - await sirsDetector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); - await sirsDetector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); + var secondAlertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = secondAlertId, + EncounterId = _encounterId, + PatientId = _patientId, + AlertType = AlertType.SofaSepsis, + Severity = AlertSeverity.Critical, + Details = "SOFA delta +3", + Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + await handler.OnSepsisAlertCreatedAsync( + _encounterId, secondAlertId, AlertType.SofaSepsis, CancellationToken.None); var bundleCount = await db.SepsisBundles.CountAsync(b => b.EncounterId == _encounterId); bundleCount.Should().Be(1, "a second bundle must not be created while one is IN_PROGRESS"); @@ -132,12 +156,10 @@ public class SepsisBundleTests : IAsyncLifetime public async Task OrderResulted_CompletesElement() { using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); var bundleService = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); - await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + await CreateSofaSepsisBundleAsync(scope); var lactateElement = await db.SepsisBundleElements .Include(e => e.Order) @@ -161,12 +183,10 @@ public class SepsisBundleTests : IAsyncLifetime public async Task AllElementsResulted_BundleCompliant() { using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); var bundleService = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); - await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + await CreateSofaSepsisBundleAsync(scope); var elements = await db.SepsisBundleElements.ToListAsync(); foreach (var element in elements) @@ -188,12 +208,10 @@ public class SepsisBundleTests : IAsyncLifetime public async Task DeadlinePassed_CompletionMarksNonCompliant() { using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); var bundleService = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); - await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + await CreateSofaSepsisBundleAsync(scope); var bundle = await db.SepsisBundles.SingleAsync(); bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1); @@ -212,11 +230,9 @@ public class SepsisBundleTests : IAsyncLifetime public async Task DeadlinePassed_MonitorMarksNonCompliant() { using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); - await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + await CreateSofaSepsisBundleAsync(scope); var bundle = await db.SepsisBundles.SingleAsync(); bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1); diff --git a/VigilCareClinicalAPI.Tests/SepsisRefactorTests.cs b/VigilCareClinicalAPI.Tests/SepsisRefactorTests.cs new file mode 100644 index 0000000..47f6c17 --- /dev/null +++ b/VigilCareClinicalAPI.Tests/SepsisRefactorTests.cs @@ -0,0 +1,191 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +[Collection("Integration")] +public class SepsisRefactorTests : IAsyncLifetime +{ + private readonly ApiFixture _fixture; + private Guid _encounterId; + private Guid _patientId; + + public SepsisRefactorTests(ApiFixture fixture) => _fixture = fixture; + + public async Task InitializeAsync() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await DbResetHelper.ResetAsync(db); + + var patient = new Patient + { + Id = Guid.NewGuid(), Mrn = "MRN-REFACTOR-001", FirstName = "Refactor", LastName = "Test", + DateOfBirth = new DateOnly(1960, 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. Refactor", AdmittedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Patients.Add(patient); + db.Encounters.Add(encounter); + await db.SaveChangesAsync(); + + _patientId = patient.Id; + _encounterId = encounter.Id; + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task SirsObservations_NoSepsisWarningAlert() + { + using var scope = _fixture.Services.CreateScope(); + var qsofa = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + await qsofa.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 39m); + await qsofa.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 110m); + await qsofa.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 22m); + await qsofa.ProcessObservationAsync(_encounterId, _patientId, "WBC_K_UL", 15m); + + (await db.ClinicalAlerts.CountAsync(a => a.AlertType == AlertType.SepsisWarning)) + .Should().Be(0); + } + + [Fact] + public async Task SofaDelta2_TriggersSepsisBundleCreation() + { + using var scope = _fixture.Services.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + var alertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = alertId, EncounterId = _encounterId, PatientId = _patientId, + AlertType = AlertType.SofaSepsis, Severity = AlertSeverity.Critical, + Details = "SOFA delta +2", Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + + await handler.OnSepsisAlertCreatedAsync( + _encounterId, alertId, AlertType.SofaSepsis, CancellationToken.None); + + (await db.SepsisBundles.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task QsofaScreen_DoesNotTriggerBundle() + { + using var scope = _fixture.Services.CreateScope(); + var detector = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); + await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + + (await db.SepsisBundles.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task QsofaScreen_IsWarningNotCritical() + { + using var scope = _fixture.Services.CreateScope(); + var detector = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); + await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + + var alert = await db.ClinicalAlerts.SingleAsync(); + alert.AlertType.Should().Be(AlertType.QsofaScreen); + alert.Severity.Should().Be(AlertSeverity.Warning); + } + + [Fact] + public void QsofaScreen_IsSuppressible() + { + AlertType.QsofaScreen.IsSuppressible().Should().BeTrue(); + } + + [Fact] + public async Task LegacySepsisWarning_StillQueryable() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var alertService = scope.ServiceProvider.GetRequiredService(); + + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = Guid.NewGuid(), EncounterId = _encounterId, PatientId = _patientId, + AlertType = AlertType.SepsisWarning, Severity = AlertSeverity.Critical, + Details = "Legacy SIRS alert", Status = AlertStatus.Resolved, + TriggeredAt = DateTimeOffset.UtcNow.AddDays(-1) + }); + await db.SaveChangesAsync(); + + var page = await alertService.ListByEncounterAsync(_encounterId, null, 1, 10); + page.Items.Should().ContainSingle(a => a.AlertType == AlertType.SepsisWarning); + } + + [Fact] + public void CannotCreateNewSepsisWarning() + { + var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning); + act.Should().Throw() + .WithMessage("*deprecated*"); + } + + [Fact] + public async Task SepsisBundleCompliance_StillTracked() + { + using var scope = _fixture.Services.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + var alertId = Guid.NewGuid(); + db.ClinicalAlerts.Add(new ClinicalAlert + { + Id = alertId, EncounterId = _encounterId, PatientId = _patientId, + AlertType = AlertType.SofaSepsis, Severity = AlertSeverity.Critical, + Details = "SOFA delta +2", Status = AlertStatus.Open, + TriggeredAt = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + await handler.OnSepsisAlertCreatedAsync( + _encounterId, alertId, AlertType.SofaSepsis, CancellationToken.None); + + var bundle = await db.SepsisBundles.SingleAsync(); + bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1); + await db.SaveChangesAsync(); + + var monitor = new SepsisBundleMonitorService( + _fixture.Services, + _fixture.Services.GetRequiredService(), + _fixture.Services.GetRequiredService>()); + await monitor.ScanOverdueBundlesAsync(CancellationToken.None); + + var updated = await db.SepsisBundles.AsNoTracking().SingleAsync(); + updated.ComplianceStatus.Should().Be(SepsisBundleComplianceStatus.NonCompliant); + } + + [Fact] + public async Task QsofaScreen_IncludesLabRecommendation() + { + using var scope = _fixture.Services.CreateScope(); + var detector = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); + await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); + + var alert = await db.ClinicalAlerts.SingleAsync(); + alert.Details.Should().Contain("order SOFA labs"); + } +} diff --git a/VigilCareClinicalAPI.Tests/SirsDetectorTests.cs b/VigilCareClinicalAPI.Tests/SirsDetectorTests.cs deleted file mode 100644 index f69a851..0000000 --- a/VigilCareClinicalAPI.Tests/SirsDetectorTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using FluentAssertions; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using StackExchange.Redis; - -[Collection("Integration")] -public class SirsDetectorTests : IAsyncLifetime -{ - private readonly ApiFixture _fixture; - private Guid _encounterId; - private Guid _patientId; - - public SirsDetectorTests(ApiFixture fixture) => _fixture = fixture; - - public async Task InitializeAsync() - { - // Reset PostgreSQL test data - using var scope = _fixture.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await DbResetHelper.ResetAsync(db); - - var patient = new Patient - { - Id = Guid.NewGuid(), Mrn = "MRN-SIRS-001", FirstName = "SIRS", LastName = "Test", - DateOfBirth = new DateOnly(1960, 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. SIRS", AdmittedAt = DateTimeOffset.UtcNow, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Patients.Add(patient); - db.Encounters.Add(encounter); - await db.SaveChangesAsync(); - - _patientId = patient.Id; - _encounterId = encounter.Id; - - // Flush all SIRS keys for this encounter from Redis - var redis = scope.ServiceProvider.GetRequiredService(); - var cache = redis.GetDatabase(); - foreach (var key in SirsEvaluator.AllCriterionKeys(_encounterId)) - await cache.KeyDeleteAsync(key); - } - - public Task DisposeAsync() => Task.CompletedTask; - - private SirsDetector CreateDetector() - { - using var scope = _fixture.Services.CreateScope(); - return scope.ServiceProvider.GetRequiredService(); - } - - // Test 1: one criterion met — insufficient for alert - [Fact] - public async Task OneCriterion_NoAlert() - { - using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); - - var result = await detector.ProcessObservationAsync( - _encounterId, _patientId, "HEART_RATE", 95m); - - result.Outcome.Should().Be(SirsOutcome.InsufficientCriteria); - result.ActiveCount.Should().Be(1); - - // Verify Redis key was set - var redis = scope.ServiceProvider.GetRequiredService(); - var ttl = await redis.GetDatabase() - .KeyTimeToLiveAsync(SirsEvaluator.CriterionKey(_encounterId, "HEART_RATE")); - ttl.Should().NotBeNull().And.BeGreaterThan(TimeSpan.Zero); - } - - // Test 2: two criteria met — SEPSIS_WARNING alert created - [Fact] - public async Task TwoCriteriaMet_AlertCreated() - { - using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); - var db = scope.ServiceProvider.GetRequiredService(); - - // Tachycardia - var r1 = await detector.ProcessObservationAsync( - _encounterId, _patientId, "HEART_RATE", 95m); - r1.Outcome.Should().Be(SirsOutcome.InsufficientCriteria); - - // Fever — now count = 2 → alert - var r2 = await detector.ProcessObservationAsync( - _encounterId, _patientId, "TEMP_C", 38.5m); - r2.Outcome.Should().Be(SirsOutcome.AlertCreated); - - // Verify clinical_alert row in PostgreSQL - var alert = await db.ClinicalAlerts.SingleAsync(); - alert.AlertType.Should().Be(AlertType.SepsisWarning); - alert.Severity.Should().Be(AlertSeverity.Critical); - alert.Status.Should().Be(AlertStatus.Open); - alert.EncounterId.Should().Be(_encounterId); - alert.PatientId.Should().Be(_patientId); - - // Verify outbox event was written in the same transaction - var outbox = await db.OutboxEvents.SingleAsync(e => e.Topic == "alert.generated"); - outbox.PartitionKey.Should().Be(_encounterId.ToString()); - } - - // Test 3: third criterion met while alert already open — no second alert - [Fact] - public async Task ThreeCriteriaMet_NoSecondAlert() - { - using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); - var db = scope.ServiceProvider.GetRequiredService(); - - await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); - // Third criterion (tachypnea) — alert already open - var r3 = await detector.ProcessObservationAsync( - _encounterId, _patientId, "RESP_RATE", 22m); - - r3.Outcome.Should().Be(SirsOutcome.AlertAlreadyOpen); - - // Still exactly one alert — the WHERE NOT EXISTS prevented a duplicate - var alertCount = await db.ClinicalAlerts.CountAsync(); - alertCount.Should().Be(1, "a second SEPSIS_WARNING must not be created while one is already open"); - } - - // Test 4: criterion clears — Redis key deleted, alert remains open - // - // This is the most important test in Phase 5. It verifies: - // (a) The DEL path works when a criterion is no longer met. - // (b) Clearing a criterion does not resolve the existing alert — the clinical - // workflow requires explicit acknowledgment. A patient whose temperature - // normalises may still be septic; the alert is for the clinician to evaluate. - [Fact] - public async Task CriterionClears_KeyDeleted_AlertRemainsOpen() - { - using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); - var db = scope.ServiceProvider.GetRequiredService(); - var redis = scope.ServiceProvider.GetRequiredService(); - var cache = redis.GetDatabase(); - - // Establish two criteria and create the alert - await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); - - // Temperature normalises - var result = await detector.ProcessObservationAsync( - _encounterId, _patientId, "TEMP_C", 37.0m); - - // TEMP_C key must be gone from Redis - var tempKeyExists = await cache.KeyExistsAsync( - SirsEvaluator.CriterionKey(_encounterId, "TEMP_C")); - tempKeyExists.Should().BeFalse("a cleared criterion must be deleted from Redis immediately"); - - // HEART_RATE key must still exist (criterion still met) - var hrKeyExists = await cache.KeyExistsAsync( - SirsEvaluator.CriterionKey(_encounterId, "HEART_RATE")); - hrKeyExists.Should().BeTrue("an active criterion must remain until its own TTL or a clearing observation"); - - // Active count is now 1, but alert must stay open - result.Outcome.Should().Be(SirsOutcome.InsufficientCriteria); - result.ActiveCount.Should().Be(1); - - var alert = await db.ClinicalAlerts.SingleAsync(); - alert.Status.Should().Be(AlertStatus.Open, - "the existing alert is not auto-resolved when criteria drop below 2 — " + - "clinical acknowledgment is required"); - } - - // Test 5: at-least-once redelivery — idempotency under simulated crash - [Fact] - public async Task DuplicateObservationEvent_AlertCreatedOnce() - { - using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); - var db = scope.ServiceProvider.GetRequiredService(); - - await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); - - // Simulate: the consumer crashes after this call completes but before committing - // the Kafka offset. On restart, the same observation is redelivered. - await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); - await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); // duplicate - - var alertCount = await db.ClinicalAlerts.CountAsync(); - alertCount.Should().Be(1, "WHERE NOT EXISTS prevents duplicate alert on redelivery"); - - var outboxCount = await db.OutboxEvents.CountAsync(e => e.Topic == "alert.generated"); - outboxCount.Should().Be(1, "outbox event must be written exactly once"); - } - - // Test 6: non-SIRS code — engine ignores it entirely - [Fact] - public async Task NonSirsCode_NoRedisInteraction() - { - using var scope = _fixture.Services.CreateScope(); - var detector = scope.ServiceProvider.GetRequiredService(); - var redis = scope.ServiceProvider.GetRequiredService(); - - var result = await detector.ProcessObservationAsync( - _encounterId, _patientId, "POTASSIUM_MEQ_L", 3.2m); - - result.Outcome.Should().Be(SirsOutcome.NotSirsCode); - - // No SIRS keys were created for this encounter - var anyKey = await redis.GetDatabase() - .KeyExistsAsync(SirsEvaluator.CriterionKey(_encounterId, "POTASSIUM_MEQ_L")); - anyKey.Should().BeFalse(); - } -} \ No newline at end of file diff --git a/VigilCareClinicalAPI.Tests/SirsEvaluatorTests.cs b/VigilCareClinicalAPI.Tests/SirsEvaluatorTests.cs deleted file mode 100644 index 9be202e..0000000 --- a/VigilCareClinicalAPI.Tests/SirsEvaluatorTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using FluentAssertions; - -public class SirsEvaluatorTests -{ - [Theory] - [InlineData("TEMP_C", 38.4, true)] // fever - [InlineData("TEMP_C", 35.9, true)] // hypothermia - [InlineData("TEMP_C", 37.0, false)] // normal - [InlineData("HEART_RATE", 91, true)] - [InlineData("HEART_RATE", 90, false)] // boundary: 90 is NOT tachycardia (> not >=) - [InlineData("RESP_RATE", 21, true)] - [InlineData("RESP_RATE", 20, false)] // boundary - [InlineData("WBC_K_UL", 12.1, true)] // leukocytosis - [InlineData("WBC_K_UL", 3.9, true)] // leukopenia - [InlineData("WBC_K_UL", 8.0, false)] // normal - [InlineData("POTASSIUM_MEQ_L", 4.0, false)] // not a SIRS code - public void MeetsCriterion_ReturnsExpected(string code, double value, bool expected) - { - SirsEvaluator.MeetsCriterion(code, (decimal)value).Should().Be(expected); - } - - [Fact] - public void AllCriterionKeys_ReturnsFourKeys_AllDistinct() - { - var id = Guid.NewGuid(); - var keys = SirsEvaluator.AllCriterionKeys(id); - keys.Should().HaveCount(4); - keys.Select(k => k.ToString()).Should().OnlyHaveUniqueItems(); - } -} \ No newline at end of file diff --git a/VigilCareClinicalAPI/BackgroundServices/SepsisEngineService.cs b/VigilCareClinicalAPI/BackgroundServices/SepsisEngineService.cs index 459d382..16cf0b9 100644 --- a/VigilCareClinicalAPI/BackgroundServices/SepsisEngineService.cs +++ b/VigilCareClinicalAPI/BackgroundServices/SepsisEngineService.cs @@ -34,13 +34,10 @@ public class SepsisEngineService : BackgroundService }; using var consumer = new ConsumerBuilder(config).Build(); - // Subscribes to observation.recorded only. - // The es-indexer consumes all three topics; the sepsis engine only needs one. - // Subscribing to a superset of needed topics would waste CPU deserializing - // alert and encounter events that this engine discards immediately. consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded); - _logger.LogInformation("SepsisEngineService started — consumer group: sepsis-engine"); + _logger.LogInformation( + "SepsisEngineService started — consumer group: sepsis-engine (qSOFA screening only)"); try { @@ -54,19 +51,9 @@ public class SepsisEngineService : BackgroundService var evt = JsonSerializer.Deserialize( result.Message.Value, EventJsonOptions)!; - // Create a scope per message — both detectors are scoped and - // each owns a fresh DbContext when creating alerts. using var scope = _services.CreateScope(); - var sirsDetector = scope.ServiceProvider.GetRequiredService(); var qsofaDetector = scope.ServiceProvider.GetRequiredService(); - var sirsOutcome = await sirsDetector.ProcessObservationAsync( - evt.EncounterId, - evt.PatientId, - evt.ObservationCode, - evt.Value, - stoppingToken); - var qsofaOutcome = await qsofaDetector.ProcessObservationAsync( evt.EncounterId, evt.PatientId, @@ -74,19 +61,12 @@ public class SepsisEngineService : BackgroundService evt.Value, stoppingToken); - if (sirsOutcome.Outcome == SirsOutcome.AlertCreated) - _logger.LogWarning( - "SEPSIS_WARNING created via SepsisEngine " + - "— encounter={EncounterId} code={Code} value={Value}", - evt.EncounterId, evt.ObservationCode, evt.Value); - if (qsofaOutcome.Outcome == QsofaOutcome.AlertCreated) - _logger.LogWarning( - "QSOFA_WARNING created via SepsisEngine " + + _logger.LogInformation( + "QSOFA_SCREEN created via SepsisEngine " + "— encounter={EncounterId} code={Code} value={Value}", evt.EncounterId, evt.ObservationCode, evt.Value); - // Commit only after successful processing. consumer.Commit(result); } catch (OperationCanceledException) @@ -98,8 +78,6 @@ public class SepsisEngineService : BackgroundService _logger.LogError(ex, "SepsisEngine failed on topic={Topic} offset={Offset} — not committing", result?.Topic, result?.Offset.Value); - // Back off before retrying so a persistent failure (e.g., Redis down) - // does not spin the loop at maximum throughput. await Task.Delay(2000, stoppingToken); } } diff --git a/VigilCareClinicalAPI/Data/Configurations/ClinicalAlertConfiguration.cs b/VigilCareClinicalAPI/Data/Configurations/ClinicalAlertConfiguration.cs index 5a702df..88e2863 100644 --- a/VigilCareClinicalAPI/Data/Configurations/ClinicalAlertConfiguration.cs +++ b/VigilCareClinicalAPI/Data/Configurations/ClinicalAlertConfiguration.cs @@ -18,13 +18,18 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration a.Id); builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); diff --git a/VigilCareClinicalAPI/Domains/Enums/AlertType.cs b/VigilCareClinicalAPI/Domains/Enums/AlertType.cs index db577e8..967907b 100644 --- a/VigilCareClinicalAPI/Domains/Enums/AlertType.cs +++ b/VigilCareClinicalAPI/Domains/Enums/AlertType.cs @@ -1,5 +1,6 @@ public enum AlertType { + [Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")] SepsisWarning, CriticalHeartRate, CriticalTempC, @@ -30,8 +31,11 @@ public enum AlertType RapidDeterioration, + [Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")] QsofaWarning, + QsofaScreen, + GcsCritical, GcsWarning, @@ -90,6 +94,7 @@ public static class AlertTypeExtensions AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL", AlertType.SofaSepsis => "SOFA_SEPSIS", AlertType.SofaWarning => "SOFA_WARNING", + AlertType.QsofaScreen => "QSOFA_SCREEN", _ => throw new ArgumentOutOfRangeException(nameof(t)) }; @@ -121,6 +126,7 @@ public static class AlertTypeExtensions "NEWS2_EMERGENCY" => AlertType.News2Emergency, "RAPID_DETERIORATION" => AlertType.RapidDeterioration, "QSOFA_WARNING" => AlertType.QsofaWarning, + "QSOFA_SCREEN" => AlertType.QsofaScreen, "GCS_CRITICAL" => AlertType.GcsCritical, "GCS_WARNING" => AlertType.GcsWarning, "CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg, @@ -189,7 +195,7 @@ public static class AlertTypeExtensions AlertType.RapidDeterioration => false, AlertType.GcsCritical => false, // trajectory alerts are never suppressed AlertType.SofaSepsis => false, - _ => true // all Warning* types, News2Warning, and QsofaWarning + _ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning }; public static string? ObservationCodeForWarning(this AlertType t) => t switch diff --git a/VigilCareClinicalAPI/Gcs/GcsDetector.cs b/VigilCareClinicalAPI/Gcs/GcsDetector.cs index 2a80264..e27d16b 100644 --- a/VigilCareClinicalAPI/Gcs/GcsDetector.cs +++ b/VigilCareClinicalAPI/Gcs/GcsDetector.cs @@ -141,6 +141,8 @@ public class GcsDetector } } + AlertCreationGuard.EnsureAllowed(alertType); + using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); diff --git a/VigilCareClinicalAPI/Migrations/20260620191432_AddQsofaScreenAlertType.cs.Designer.cs b/VigilCareClinicalAPI/Migrations/20260620191432_AddQsofaScreenAlertType.cs.Designer.cs new file mode 100644 index 0000000..af48b94 --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260620191432_AddQsofaScreenAlertType.cs.Designer.cs @@ -0,0 +1,1085 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260620191432_AddQsofaScreenAlertType.cs")] + partial class AddQsofaScreenAlertTypecs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AlertThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("CriticalHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_high"); + + b.Property("CriticalLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("critical_low"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("SuppressionWindowMinutes") + .HasColumnType("integer") + .HasColumnName("suppression_window_minutes"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("WarningHigh") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_high"); + + b.Property("WarningLow") + .HasColumnType("decimal(10,3)") + .HasColumnName("warning_low"); + + b.HasKey("Id"); + + b.HasIndex("ObservationCode") + .IsUnique(); + + b.ToTable("alert_thresholds", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("acknowledged_by"); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("alert_type"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("ObservationId") + .HasColumnType("uuid") + .HasColumnName("observation_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'OPEN'"); + + b.Property("TriggeredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("triggered_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "TriggeredAt"); + + b.HasIndex("PatientId", "TriggeredAt"); + + b.HasIndex("Severity", "TriggeredAt") + .HasFilter("status = 'OPEN'"); + + b.ToTable("clinical_alerts", null, t => + { + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')"); + + t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); + + t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')"); + }); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdmissionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("admission_reason"); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("admitted_at") + .HasDefaultValueSql("NOW()"); + + b.Property("AttendingPhysician") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("attending_physician"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Department") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("department"); + + b.Property("DischargeDiagnosis") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("discharge_diagnosis"); + + b.Property("DischargedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("discharged_at"); + + b.Property("EncounterType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("encounter_type"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RoomBed") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("room_bed"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'SCHEDULED'"); + + b.HasKey("Id"); + + b.HasIndex("PatientId", "AdmittedAt"); + + b.HasIndex("Status", "AdmittedAt") + .HasFilter("status = 'ACTIVE'"); + + b.ToTable("encounters", null, t => + { + t.HasCheckConstraint("chk_encounters_department", "department IN ('ICU', 'GENERAL_MEDICINE', 'EMERGENCY', 'CARDIOLOGY', 'SURGERY', 'PEDIATRICS')"); + + t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')"); + + t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("Classification") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("classification"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("EyeScore") + .HasColumnType("integer") + .HasColumnName("eye_score"); + + b.Property("MotorScore") + .HasColumnType("integer") + .HasColumnName("motor_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.Property("VerbalScore") + .HasColumnType("integer") + .HasColumnName("verbal_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("gcs_scores", null, t => + { + t.HasCheckConstraint("chk_gcs_scores_classification", "classification IN ('MILD', 'MODERATE', 'SEVERE')"); + }); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AdministeredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("administered_at"); + + b.Property("AdministeredBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("administered_by"); + + b.Property("Dose") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)") + .HasColumnName("dose"); + + b.Property("DoseUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("dose_unit"); + + b.Property("DrugName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("drug_name"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("route"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "AdministeredAt"); + + b.HasIndex("EncounterId", "DrugName"); + + b.ToTable("medication_administrations", (string)null); + }); + + modelBuilder.Entity("News2Score", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("ConsciousnessScore") + .HasColumnType("integer") + .HasColumnName("consciousness_score"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("HasSingleParamThree") + .HasColumnType("boolean") + .HasColumnName("has_single_param_three"); + + b.Property("HeartRateScore") + .HasColumnType("integer") + .HasColumnName("heart_rate_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RespRateScore") + .HasColumnType("integer") + .HasColumnName("resp_rate_score"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("risk_level"); + + b.Property("Spo2Score") + .HasColumnType("integer") + .HasColumnName("spo2_score"); + + b.Property("SupplementalO2Score") + .HasColumnType("integer") + .HasColumnName("supplemental_o2_score"); + + b.Property("SystolicBpScore") + .HasColumnType("integer") + .HasColumnName("systolic_bp_score"); + + b.Property("TemperatureScore") + .HasColumnType("integer") + .HasColumnName("temperature_score"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.HasIndex("PatientId", "CalculatedAt"); + + b.ToTable("news2_scores", null, t => + { + t.HasCheckConstraint("chk_news2_scores_risk_level", "risk_level IN ('LOW', 'LOW_MEDIUM', 'MEDIUM', 'HIGH')"); + }); + }); + + modelBuilder.Entity("Observation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IdempotencyKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("idempotency_key"); + + b.Property("ObservationCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("observation_code"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source") + .HasDefaultValueSql("'MANUAL'"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("unit"); + + b.Property("Value") + .HasColumnType("decimal(10,3)") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("idempotency_key IS NOT NULL"); + + b.HasIndex("EncounterId", "ObservationCode", "RecordedAt"); + + b.ToTable("observations", null, t => + { + t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')"); + }); + }); + + modelBuilder.Entity("Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("OrderType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("order_type"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("NOW()"); + + b.Property("OrderedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("ordered_by"); + + b.Property("ResultSummary") + .HasColumnType("text") + .HasColumnName("result_summary"); + + b.Property("ResultedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resulted_at"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId", "OrderedAt"); + + b.HasIndex("Status", "OrderedAt") + .HasFilter("status IN ('PENDING', 'IN_PROGRESS')"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')"); + + t.HasCheckConstraint("chk_orders_status", "status IN ('PENDING', 'IN_PROGRESS', 'RESULTED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("OutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("PartitionKey") + .HasMaxLength(36) + .HasColumnType("character varying(36)") + .HasColumnName("partition_key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Topic") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("topic"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasFilter("processed_at IS NULL"); + + b.ToTable("outbox_events", (string)null); + }); + + modelBuilder.Entity("Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Allergies") + .HasColumnType("text") + .HasColumnName("allergies"); + + b.Property("BloodType") + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("blood_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("emergency_contact_name"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("emergency_contact_phone"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("gender"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.Property("Mrn") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mrn"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("Mrn") + .IsUnique(); + + b.ToTable("patients", (string)null); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("check_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId"); + + b.HasIndex("PatientId"); + + b.HasIndex("CheckType", "EncounterId") + .HasFilter("resolved_at IS NULL"); + + b.ToTable("reconciliation_alerts", null, t => + { + t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')"); + }); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ComplianceStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("compliance_status") + .HasDefaultValueSql("'IN_PROGRESS'"); + + b.Property("DeadlineAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deadline_at"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("RecognizedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recognized_at"); + + b.Property("TriggeringAlertId") + .HasColumnType("uuid") + .HasColumnName("triggering_alert_id"); + + b.Property("TriggeringAlertType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("triggering_alert_type"); + + b.HasKey("Id"); + + b.HasIndex("ComplianceStatus"); + + b.HasIndex("TriggeringAlertId"); + + b.HasIndex("EncounterId", "RecognizedAt"); + + b.ToTable("sepsis_bundles", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundles_compliance_status", "compliance_status IN ('IN_PROGRESS', 'COMPLIANT', 'NON_COMPLIANT')"); + }); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BundleId") + .HasColumnType("uuid") + .HasColumnName("bundle_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ElementType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("element_type"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status") + .HasDefaultValueSql("'PENDING'"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("BundleId", "ElementType") + .IsUnique(); + + b.ToTable("sepsis_bundle_elements", null, t => + { + t.HasCheckConstraint("chk_sepsis_bundle_elements_element_type", "element_type IN ('BLOOD_CULTURES', 'SERUM_LACTATE', 'BROAD_SPECTRUM_ANTIBIOTICS', 'IV_FLUID_RESUSCITATION')"); + + t.HasCheckConstraint("chk_sepsis_bundle_elements_status", "status IN ('PENDING', 'COMPLETED')"); + }); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CalculatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("calculated_at"); + + b.Property("CardiovascularScore") + .HasColumnType("integer") + .HasColumnName("cardiovascular_score"); + + b.Property("CnsScore") + .HasColumnType("integer") + .HasColumnName("cns_score"); + + b.Property("CoagulationScore") + .HasColumnType("integer") + .HasColumnName("coagulation_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeltaFromBaseline") + .HasColumnType("integer") + .HasColumnName("delta_from_baseline"); + + b.Property("EncounterId") + .HasColumnType("uuid") + .HasColumnName("encounter_id"); + + b.Property("IsBaseline") + .HasColumnType("boolean") + .HasColumnName("is_baseline"); + + b.Property("LiverScore") + .HasColumnType("integer") + .HasColumnName("liver_score"); + + b.Property("PatientId") + .HasColumnType("uuid") + .HasColumnName("patient_id"); + + b.Property("RenalScore") + .HasColumnType("integer") + .HasColumnName("renal_score"); + + b.Property("RespiratoryScore") + .HasColumnType("integer") + .HasColumnName("respiratory_score"); + + b.Property("StalenessFlags") + .HasColumnType("jsonb") + .HasColumnName("staleness_flags"); + + b.Property("TotalScore") + .HasColumnType("integer") + .HasColumnName("total_score"); + + b.HasKey("Id"); + + b.HasIndex("EncounterId") + .HasDatabaseName("idx_sofa_scores_baseline") + .HasFilter("is_baseline = true"); + + b.HasIndex("EncounterId", "CalculatedAt"); + + b.ToTable("sofa_scores", (string)null); + }); + + modelBuilder.Entity("ClinicalAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Alerts") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.HasOne("Patient", "Patient") + .WithMany("Encounters") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("GcsScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("MedicationAdministration", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("News2Score", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Observation", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Observations") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Order", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany("Orders") + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("ReconciliationAlert", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Encounter"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ClinicalAlert", "TriggeringAlert") + .WithMany() + .HasForeignKey("TriggeringAlertId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + + b.Navigation("TriggeringAlert"); + }); + + modelBuilder.Entity("SepsisBundleElement", b => + { + b.HasOne("SepsisBundle", "Bundle") + .WithMany("Elements") + .HasForeignKey("BundleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Bundle"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("SofaScore", b => + { + b.HasOne("Encounter", "Encounter") + .WithMany() + .HasForeignKey("EncounterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Encounter"); + }); + + modelBuilder.Entity("Encounter", b => + { + b.Navigation("Alerts"); + + b.Navigation("Observations"); + + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Patient", b => + { + b.Navigation("Encounters"); + }); + + modelBuilder.Entity("SepsisBundle", b => + { + b.Navigation("Elements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/20260620191432_AddQsofaScreenAlertType.cs.cs b/VigilCareClinicalAPI/Migrations/20260620191432_AddQsofaScreenAlertType.cs.cs new file mode 100644 index 0000000..6d526ea --- /dev/null +++ b/VigilCareClinicalAPI/Migrations/20260620191432_AddQsofaScreenAlertType.cs.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCareClinicalAPI.Migrations +{ + /// + public partial class AddQsofaScreenAlertTypecs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type; + ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type + CHECK (alert_type IN ( + 'SEPSIS_WARNING', + 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', + 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', + 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', + 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', + 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', + 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', + 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', + 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', + 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', + 'WARNING_GLUCOSE_MG_DL', + 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', + 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', + 'NEWS2_WARNING', 'NEWS2_EMERGENCY', + 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', + 'GCS_CRITICAL', 'GCS_WARNING', + 'SOFA_SEPSIS', 'SOFA_WARNING' + )); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs index 6bf4a50..db40265 100644 --- a/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs +++ b/VigilCareClinicalAPI/Migrations/AppDbContextModelSnapshot.cs @@ -156,7 +156,7 @@ namespace VigilCareClinicalAPI.Migrations b.ToTable("clinical_alerts", null, t => { - t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'GCS_CRITICAL', 'GCS_WARNING')"); + t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', 'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', 'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', 'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL', 'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL', 'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', 'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', 'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', 'WARNING_GLUCOSE_MG_DL', 'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', 'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', 'NEWS2_WARNING', 'NEWS2_EMERGENCY', 'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', 'GCS_CRITICAL', 'GCS_WARNING', 'SOFA_SEPSIS', 'SOFA_WARNING')"); t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')"); diff --git a/VigilCareClinicalAPI/Models/Records/Sepsis/SirsOutcome.cs b/VigilCareClinicalAPI/Models/Records/Sepsis/SirsOutcome.cs deleted file mode 100644 index d1e1705..0000000 --- a/VigilCareClinicalAPI/Models/Records/Sepsis/SirsOutcome.cs +++ /dev/null @@ -1,7 +0,0 @@ -public enum SirsOutcome -{ - NotSirsCode, - InsufficientCriteria, - AlertCreated, - AlertAlreadyOpen -} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Sepsis/SirsResult.cs b/VigilCareClinicalAPI/Models/Records/Sepsis/SirsResult.cs deleted file mode 100644 index 3719fbc..0000000 --- a/VigilCareClinicalAPI/Models/Records/Sepsis/SirsResult.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Discriminated result — allows tests and callers to assert the exact outcome -// without inspecting PostgreSQL or Redis directly. -public record SirsResult(SirsOutcome Outcome, int ActiveCount = 0) -{ - public static readonly SirsResult NotSirsCode = new(SirsOutcome.NotSirsCode); - public static readonly SirsResult AlertCreated = new(SirsOutcome.AlertCreated); - public static readonly SirsResult AlertAlreadyOpen = new(SirsOutcome.AlertAlreadyOpen); - - public static SirsResult InsufficientCriteria(int count) => - new(SirsOutcome.InsufficientCriteria, count); -} \ No newline at end of file diff --git a/VigilCareClinicalAPI/News2/News2Detector.cs b/VigilCareClinicalAPI/News2/News2Detector.cs index 2e62c5d..e51846a 100644 --- a/VigilCareClinicalAPI/News2/News2Detector.cs +++ b/VigilCareClinicalAPI/News2/News2Detector.cs @@ -199,6 +199,8 @@ public class News2Detector } } + AlertCreationGuard.EnsureAllowed(alertType); + using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); diff --git a/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs b/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs index abc56f5..591074b 100644 --- a/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs +++ b/VigilCareClinicalAPI/Observability/Metrics/ClinicalMetrics.cs @@ -11,19 +11,12 @@ public sealed class ClinicalMetrics "Total observations ingested, labeled by observation code and source.", labelNames: new[] { "observation_code", "source" }); - // Labeled by alert_type (THRESHOLD_BREACH, SEPSIS_WARNING, QSOFA_WARNING) and severity - // (Critical, Warning) so the dashboard can show Critical vs Warning rates separately. + // Labeled by alert_type and severity (e.g. QSOFA_SCREEN/WARNING, SOFA_SEPSIS/CRITICAL). public readonly Counter ClinicalAlertsTotal = Metrics.CreateCounter( "clinical_alerts_total", "Total clinical alerts generated, labeled by type and severity.", labelNames: new[] { "alert_type", "severity" }); - // Incremented only when INSERT WHERE NOT EXISTS succeeds — duplicate-suppressed - // SIRS detections do not count. This is the true detection rate, not the evaluation rate. - public readonly Counter SirsDetectionsTotal = Metrics.CreateCounter( - "sirs_detections_total", - "Total SEPSIS_WARNING alerts generated by the sepsis detection engine."); - public readonly Counter News2ScoresTotal = Metrics.CreateCounter( "news2_scores_total", "Total NEWS2 scores computed, labeled by risk level.", @@ -48,7 +41,7 @@ public sealed class ClinicalMetrics public readonly Counter QsofaDetectionsTotal = Metrics.CreateCounter( "qsofa_detections_total", - "Total QSOFA_WARNING alerts generated by the qSOFA scoring engine."); + "Total QSOFA_SCREEN alerts generated by the qSOFA screening engine."); public readonly Counter SepsisBundleComplianceTotal = Metrics.CreateCounter( "sepsis_bundle_compliance_total", diff --git a/VigilCareClinicalAPI/Program.cs b/VigilCareClinicalAPI/Program.cs index 8e5b17c..98905ea 100644 --- a/VigilCareClinicalAPI/Program.cs +++ b/VigilCareClinicalAPI/Program.cs @@ -105,7 +105,6 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/VigilCareClinicalAPI/Sepsis/AlertCreationGuard.cs b/VigilCareClinicalAPI/Sepsis/AlertCreationGuard.cs new file mode 100644 index 0000000..5281c03 --- /dev/null +++ b/VigilCareClinicalAPI/Sepsis/AlertCreationGuard.cs @@ -0,0 +1,9 @@ +public static class AlertCreationGuard +{ + public static void EnsureAllowed(AlertType alertType) + { + if (alertType == AlertType.SepsisWarning) + throw new InvalidOperationException( + "SEPSIS_WARNING is deprecated. Use SOFA_SEPSIS for sepsis detection."); + } +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Sepsis/QsofaCalculator.cs b/VigilCareClinicalAPI/Sepsis/QsofaCalculator.cs index 1ede568..c6e8fd7 100644 --- a/VigilCareClinicalAPI/Sepsis/QsofaCalculator.cs +++ b/VigilCareClinicalAPI/Sepsis/QsofaCalculator.cs @@ -1,5 +1,9 @@ using StackExchange.Redis; + // qSOFA criteria (Sepsis-3 bedside screen): +// - Respiratory rate ≥ 22 breaths/min +// - Systolic blood pressure ≤ 100 mmHg +// - Altered mentation: AVPU ≥ 1 or GCS total < 15 (via SyncAlteredMentationAsync) public static class QsofaCalculator { public static readonly IReadOnlyList QsofaCodes = new[] diff --git a/VigilCareClinicalAPI/Sepsis/QsofaDetector.cs b/VigilCareClinicalAPI/Sepsis/QsofaDetector.cs index e95a8df..5f10438 100644 --- a/VigilCareClinicalAPI/Sepsis/QsofaDetector.cs +++ b/VigilCareClinicalAPI/Sepsis/QsofaDetector.cs @@ -5,7 +5,6 @@ using StackExchange.Redis; public class QsofaDetector { - // 30 minutes in seconds — same sliding window as SIRS. private const int QsofaTtlSeconds = 1800; private readonly IConnectionMultiplexer _redis; @@ -42,30 +41,13 @@ public class QsofaDetector { await cache.StringSetAsync( key, value.ToString(), TimeSpan.FromSeconds(QsofaTtlSeconds)); - - _logger.LogDebug("qSOFA criterion set: {Key}={Value} (TTL={Ttl}s)", - key, value, QsofaTtlSeconds); } else { await cache.KeyDeleteAsync(key); - - _logger.LogDebug("qSOFA criterion cleared: {Key}", key); } - var allKeys = QsofaCalculator.AllCriterionKeys(encounterId); - var values = await cache.StringGetAsync(allKeys); - var activeCount = QsofaCalculator.CountActiveCriteria(values); - - _logger.LogDebug( - "qSOFA state for encounter {Id}: {Active}/3 criteria active after {Code}={Value}", - encounterId, activeCount, observationCode, value); - - if (activeCount < 2) - return QsofaResult.InsufficientCriteria(activeCount); - - var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, values, ct); - return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen; + return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct); } public async Task SyncAlteredMentationAsync( @@ -85,16 +67,18 @@ public class QsofaDetector var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value; if (QsofaCalculator.MeetsGcsAlteredMentation(total)) - { - await cache.StringSetAsync( - avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds)); - } + await cache.StringSetAsync(avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds)); else - { await cache.KeyDeleteAsync(avpuKey); - } } + return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct); + } + + private async Task EvaluateAndMaybeAlertAsync( + Guid encounterId, Guid patientId, CancellationToken ct) + { + var cache = _redis.GetDatabase(); var allKeys = QsofaCalculator.AllCriterionKeys(encounterId); var values = await cache.StringGetAsync(allKeys); var activeCount = QsofaCalculator.CountActiveCriteria(values); @@ -102,17 +86,19 @@ public class QsofaDetector if (activeCount < 2) return QsofaResult.InsufficientCriteria(activeCount); - var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, values, ct); + var created = await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct); return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen; } - private async Task TryCreateAlertAsync( + private async Task TryCreateScreenAlertAsync( Guid encounterId, Guid patientId, int activeCount, RedisValue[] criterionValues, CancellationToken ct) { + AlertCreationGuard.EnsureAllowed(AlertType.QsofaScreen); + using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -120,17 +106,21 @@ public class QsofaDetector var alertId = Guid.NewGuid(); var triggeredAt = DateTimeOffset.UtcNow; - var details = BuildDetails(activeCount, criterionValues); + var activeCriteria = BuildActiveCriteriaList(criterionValues); + var details = + $"qSOFA score ≥ 2 (criteria: {activeCriteria}). " + + "Recommend: order SOFA labs (PaO2/FiO2, platelets, bilirubin, creatinine) " + + "to evaluate for organ dysfunction."; 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}, - 'QSOFA_WARNING', 'CRITICAL', {details}, 'OPEN', {triggeredAt} + 'QSOFA_SCREEN', 'WARNING', {details}, 'OPEN', {triggeredAt} WHERE NOT EXISTS ( SELECT 1 FROM clinical_alerts WHERE encounter_id = {encounterId} - AND alert_type = 'QSOFA_WARNING' + AND alert_type = 'QSOFA_SCREEN' AND status IN ('OPEN', 'ESCALATED') ) """, ct); @@ -138,8 +128,6 @@ public class QsofaDetector if (affected == 0) { await tx.RollbackAsync(ct); - _logger.LogDebug( - "QSOFA_WARNING already open for encounter {Id} — no new alert", encounterId); return false; } @@ -152,8 +140,8 @@ public class QsofaDetector alertId, encounterId, patientId, - alertType = AlertType.QsofaWarning.ToDbString(), - severity = "Critical", + alertType = AlertType.QsofaScreen.ToDbString(), + severity = AlertSeverity.Warning.ToDbString(), details, triggeredAt, partitionKey = encounterId.ToString() @@ -167,24 +155,22 @@ public class QsofaDetector _metrics.QsofaDetectionsTotal.Inc(); _metrics.ClinicalAlertsTotal - .WithLabels(AlertType.QsofaWarning.ToDbString(), AlertSeverity.Critical.ToDbString()) + .WithLabels(AlertType.QsofaScreen.ToDbString(), AlertSeverity.Warning.ToDbString()) .Inc(); using (LogContext.PushProperty("EncounterId", encounterId)) using (LogContext.PushProperty("PatientId", patientId)) { _logger.LogWarning( - "QSOFA_WARNING created. ActiveCriteriaCount={Count} AlertId={AlertId}", + "QSOFA_SCREEN created. ActiveCriteriaCount={Count} AlertId={AlertId}", activeCount, alertId); } - var handler = scope.ServiceProvider.GetRequiredService(); - await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.QsofaWarning, ct); - + // Screening alert — does NOT trigger sepsis bundle (Phase 27 Step 4) return true; } - private static string BuildDetails(int activeCount, RedisValue[] values) + private static string BuildActiveCriteriaList(RedisValue[] values) { var activeParts = new List(); for (var i = 0; i < QsofaCalculator.QsofaCodes.Count; i++) @@ -192,7 +178,6 @@ public class QsofaDetector if (values[i].HasValue) activeParts.Add($"{QsofaCalculator.QsofaCodes[i]}={values[i]}"); } - - return $"qSOFA score {activeCount}/3: {string.Join(", ", activeParts)}"; + return string.Join(", ", activeParts); } -} +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Sepsis/SepsisAlertHandler.cs b/VigilCareClinicalAPI/Sepsis/SepsisAlertHandler.cs index 42fee61..ba04173 100644 --- a/VigilCareClinicalAPI/Sepsis/SepsisAlertHandler.cs +++ b/VigilCareClinicalAPI/Sepsis/SepsisAlertHandler.cs @@ -12,6 +12,14 @@ public class SepsisAlertHandler public async Task OnSepsisAlertCreatedAsync( Guid encounterId, Guid alertId, AlertType alertType, CancellationToken ct) { + if (alertType != AlertType.SofaSepsis) + { + _logger.LogDebug( + "Alert type {AlertType} does not trigger sepsis bundle — skipping", + alertType.ToDbString()); + return; + } + var bundle = await _bundleService.TryCreateBundleAsync(encounterId, alertId, alertType, ct); if (bundle is not null) @@ -19,4 +27,4 @@ public class SepsisAlertHandler "Sepsis bundle {BundleId} created for encounter {EncounterId} (trigger={AlertType})", bundle.Id, encounterId, alertType.ToDbString()); } -} +} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Sepsis/SirsDetector.cs b/VigilCareClinicalAPI/Sepsis/SirsDetector.cs deleted file mode 100644 index 019ef65..0000000 --- a/VigilCareClinicalAPI/Sepsis/SirsDetector.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Serilog.Context; -using StackExchange.Redis; - -public class SirsDetector -{ - // 30 minutes in seconds. This is a clinical parameter: SIRS criteria evaluated - // outside a 30-minute window are clinically stale. The TTL enforces the window - // automatically — no cleanup job required. - private const int SirsTtlSeconds = 1800; - - private readonly IConnectionMultiplexer _redis; - private readonly IServiceProvider _services; - private readonly ILogger _logger; - private readonly ClinicalMetrics _metrics; - - public SirsDetector( - IConnectionMultiplexer redis, - IServiceProvider services, - ILogger logger, - ClinicalMetrics metrics) - { - _redis = redis; - _services = services; - _logger = logger; - _metrics = metrics; - } - - public async Task ProcessObservationAsync( - Guid encounterId, - Guid patientId, - string observationCode, - decimal value, - CancellationToken ct = default) - { - // Fast exit for non-SIRS codes. The sepsis engine subscribes to the full - // observation.recorded stream — the majority of messages (SpO2, potassium, glucose) - // are not SIRS-relevant and are discarded here without touching Redis or PostgreSQL. - if (!SirsEvaluator.SirsCodes.Contains(observationCode)) - return SirsResult.NotSirsCode; - - var cache = _redis.GetDatabase(); - var key = SirsEvaluator.CriterionKey(encounterId, observationCode); - - if (SirsEvaluator.MeetsCriterion(observationCode, value)) - { - // SET with EX refreshes the TTL on every qualifying observation. - // A patient with tachycardia posting a reading every 60 seconds will keep - // sirs:{id}:HEART_RATE alive for 30 minutes after the LAST qualifying reading, - // not the first — the window slides forward with each new abnormal value. - await cache.StringSetAsync(key, "1", TimeSpan.FromSeconds(SirsTtlSeconds)); - - _logger.LogDebug("SIRS criterion set: {Key} (TTL={Ttl}s)", key, SirsTtlSeconds); - } - else - { - // Criterion no longer met — remove the key immediately rather than waiting - // for TTL expiry. If a patient's temperature normalises at 37.0 °C, the - // fever criterion must stop contributing to the count right away. - // Without this DEL, a recovered criterion could persist for up to 30 minutes - // and falsely sustain a SEPSIS_WARNING count. - await cache.KeyDeleteAsync(key); - - _logger.LogDebug("SIRS criterion cleared: {Key}", key); - } - - // Count active criteria in one MGET round-trip. - // MGET is O(N) where N = number of keys requested (4 here, always). - // Never use KEYS pattern for this check: KEYS scans the entire keyspace - // and blocks all other Redis operations until the scan completes. - var allKeys = SirsEvaluator.AllCriterionKeys(encounterId); - var values = await cache.StringGetAsync(allKeys); - var activeCount = values.Count(v => v.HasValue); - - _logger.LogDebug( - "SIRS state for encounter {Id}: {Active}/4 criteria active after {Code}={Value}", - encounterId, activeCount, observationCode, value); - - if (activeCount < 2) - return SirsResult.InsufficientCriteria(activeCount); - - // Two or more criteria are active — attempt to create the alert. - var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, ct); - return created ? SirsResult.AlertCreated : SirsResult.AlertAlreadyOpen; - } - - // Creates the SEPSIS_WARNING alert and its outbox event in one atomic transaction. - // The INSERT WHERE NOT EXISTS pattern makes this safe under at-least-once delivery: - // if the consumer crashes after the INSERT but before committing the Kafka offset, - // the observation is reprocessed on restart. The second run hits the WHERE NOT EXISTS - // subquery, finds the existing open alert, inserts 0 rows, and returns false — no - // duplicate alert, no duplicate outbox event. - private async Task TryCreateAlertAsync( - Guid encounterId, - Guid patientId, - int activeCount, - CancellationToken ct) - { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - await using var tx = await db.Database.BeginTransactionAsync(ct); - - var alertId = Guid.NewGuid(); - var triggeredAt = DateTimeOffset.UtcNow; - var details = - $"SIRS criteria met: {activeCount} of 4 criteria active within the 30-minute window."; - - // One SQL round-trip: check + insert atomically. - // status IN ('OPEN', 'ESCALATED') prevents re-creating an alert that has been - // escalated but not yet resolved — the patient is still in danger. - 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}, - 'SEPSIS_WARNING', 'CRITICAL', {details}, 'OPEN', {triggeredAt} - WHERE NOT EXISTS ( - SELECT 1 FROM clinical_alerts - WHERE encounter_id = {encounterId} - AND alert_type = 'SEPSIS_WARNING' - AND status IN ('OPEN', 'ESCALATED') - ) - """, ct); - - if (affected == 0) - { - await tx.RollbackAsync(ct); - _logger.LogDebug( - "SEPSIS_WARNING already open for encounter {Id} — no new alert", encounterId); - return false; - } - - // Alert was created — write the outbox event in the same transaction. - // The relay (Phase 3) will publish to alert.generated, which Phase 6's - // notification worker reads to page the attending physician via RabbitMQ. - db.OutboxEvents.Add(new OutboxEvent - { - Id = Guid.NewGuid(), - Topic = "alert.generated", - Payload = JsonSerializer.Serialize(new - { - alertId, - encounterId, - patientId, - alertType = AlertType.SepsisWarning.ToDbString(), - severity = "Critical", - details, - triggeredAt, - partitionKey = encounterId.ToString() - }), - PartitionKey = encounterId.ToString(), - CreatedAt = DateTimeOffset.UtcNow - }); - - await db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - - _metrics.SirsDetectionsTotal.Inc(); - _metrics.ClinicalAlertsTotal - .WithLabels(AlertType.SepsisWarning.ToDbString(), AlertSeverity.Critical.ToDbString()) - .Inc(); - - using (LogContext.PushProperty("EncounterId", encounterId)) - using (LogContext.PushProperty("PatientId", patientId)) - { - _logger.LogWarning( - "SEPSIS_WARNING created. ActiveCriteriaCount={Count} AlertId={AlertId}", - activeCount, alertId); - } - - var handler = scope.ServiceProvider.GetRequiredService(); - await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.SepsisWarning, ct); - - return true; - } -} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Sepsis/SirsEvaluator.cs b/VigilCareClinicalAPI/Sepsis/SirsEvaluator.cs deleted file mode 100644 index 3d75fab..0000000 --- a/VigilCareClinicalAPI/Sepsis/SirsEvaluator.cs +++ /dev/null @@ -1,38 +0,0 @@ -using StackExchange.Redis; - -public static class SirsEvaluator -{ - // The four SIRS codes defined by this project's simplified SIRS criteria. - // Observations for any other code are ignored by the sepsis engine entirely — - // they pass through to the outbox and Elasticsearch but do not affect SIRS state. - public static readonly IReadOnlySet SirsCodes = - new HashSet { "TEMP_C", "HEART_RATE", "RESP_RATE", "WBC_K_UL" }; - - // Returns true if the observation value meets the SIRS criterion for its code. - // These thresholds are clinical parameters, not configuration — changing them - // requires clinical review, not a config file edit. They live here as named constants. - public static bool MeetsCriterion(string observationCode, decimal value) => - observationCode switch - { - // Fever (> 38.3 °C) or hypothermia (< 36.0 °C) - "TEMP_C" => value > 38.3m || value < 36.0m, - // Tachycardia - "HEART_RATE" => value > 90m, - // Tachypnea - "RESP_RATE" => value > 20m, - // Leukocytosis or leukopenia - "WBC_K_UL" => value > 12.0m || value < 4.0m, - _ => false - }; - - // Redis key for one SIRS criterion for one encounter. - public static string CriterionKey(Guid encounterId, string code) => - $"sirs:{encounterId}:{code}"; - - // All four Redis keys for one encounter — used in MGET to count active criteria. - // The order is stable so the MGET result array always maps to the same codes. - public static RedisKey[] AllCriterionKeys(Guid encounterId) => - SirsCodes - .Select(code => (RedisKey)CriterionKey(encounterId, code)) - .ToArray(); -} \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/SepsisBundleService.cs b/VigilCareClinicalAPI/Services/SepsisBundleService.cs index 9c4b9f2..5275741 100644 --- a/VigilCareClinicalAPI/Services/SepsisBundleService.cs +++ b/VigilCareClinicalAPI/Services/SepsisBundleService.cs @@ -17,6 +17,10 @@ public class SepsisBundleService : ISepsisBundleService public async Task TryCreateBundleAsync( Guid encounterId, Guid triggeringAlertId, AlertType alertType, CancellationToken ct = default) { + if (alertType != AlertType.SofaSepsis) + throw new InvalidOperationException( + $"Sepsis bundle can only be triggered by SOFA_SEPSIS, not {alertType.ToDbString()}."); + var exists = await _db.SepsisBundles .AnyAsync(b => b.EncounterId == encounterId && b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress, ct); diff --git a/VigilCareClinicalAPI/Sofa/SofaDetector.cs b/VigilCareClinicalAPI/Sofa/SofaDetector.cs index bf085d3..52fa7ba 100644 --- a/VigilCareClinicalAPI/Sofa/SofaDetector.cs +++ b/VigilCareClinicalAPI/Sofa/SofaDetector.cs @@ -276,6 +276,8 @@ public class SofaDetector return false; } + AlertCreationGuard.EnsureAllowed(alertType); + using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -332,6 +334,12 @@ public class SofaDetector await db.SaveChangesAsync(ct); await tx.CommitAsync(ct); + if (alertType == AlertType.SofaSepsis) + { + var handler = _services.GetRequiredService(); + await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, alertType, ct); + } + _metrics.ClinicalAlertsTotal .WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc(); diff --git a/docs/decisions/sepsis-engine-design.md b/docs/decisions/sepsis-engine-design.md index 7b486f4..09f1b51 100644 --- a/docs/decisions/sepsis-engine-design.md +++ b/docs/decisions/sepsis-engine-design.md @@ -1,4 +1,3 @@ -```markdown # Sepsis Engine Design Decisions ## Why Redis for SIRS state, not a PostgreSQL time-range query @@ -50,4 +49,21 @@ and no additional infrastructure. The trade-off: if this system needed to scale multi-hospital network with 50,000+ concurrent inpatients (~5,000 observations/second), Flink would become the right choice. The architecture decision is correct at this scale and defensible at interview with a clear scale inflection point named. -``` \ No newline at end of file + +## Phase 27 — Migration from SIRS to SOFA + +**Rationale:** Sepsis-3 (2016) replaced SIRS with SOFA for organ dysfunction assessment. +SIRS is non-specific (post-exercise tachycardia, mild fever). Doctor feedback aligned with +moving sepsis **confirmation** to SOFA delta ≥ 2 while retaining qSOFA as a **bedside screen**. + +**What changed:** +- `SirsDetector` / `SirsEvaluator` deleted — no new `SEPSIS_WARNING` alerts +- qSOFA ≥ 2 → `QSOFA_SCREEN` (WARNING, suppressible) with lab-order recommendation +- Sepsis hour-1 bundle triggers from `SOFA_SEPSIS` only (Phase 26 delta ≥ 2) +- Historical `SEPSIS_WARNING` and `QSOFA_WARNING` rows remain queryable + +**Redis key patterns after Phase 27:** +- `qsofa:{encounterId}:{code}` — qSOFA screening (30 min TTL) +- `sofa:{encounterId}:{code}` — SOFA lab carry-forward (Phase 26) +- `gcs:{encounterId}:{code}` — GCS components (Phase 25) +- ~~`sirs:{encounterId}:{code}`~~ — removed (legacy keys expire) diff --git a/scripts/run-phase27-verification.sh b/scripts/run-phase27-verification.sh new file mode 100755 index 0000000..934c33e --- /dev/null +++ b/scripts/run-phase27-verification.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +API_DIR="${ROOT_DIR}/VigilCareClinicalAPI" + +echo "=== Phase 27 verification ===" + +echo "1. SIRS files removed" +test ! -f "${API_DIR}/Sepsis/SirsDetector.cs" +test ! -f "${API_DIR}/Sepsis/SirsEvaluator.cs" + +echo "2. Sepsis refactor integration tests" +dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" \ + --filter "FullyQualifiedName~SepsisRefactor" \ + --no-restore + +echo "3. Full regression suite" +dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.Tests" --no-restore + +echo "4. Manual checks (requires running stack)" +BASE_URL="${BASE_URL:-http://localhost:5270}" +echo " - Replay uti-sepsis-elderly-01 → expect QSOFA_SCREEN, not SEPSIS_WARNING" +echo " - Ingest SOFA labs with delta ≥ 2 → expect SOFA_SEPSIS + bundle" +echo " - GET ${BASE_URL}/api/v1/alerts?alertType=SEPSIS_WARNING → legacy rows still returned" + +echo "Phase 27 verification complete." diff --git a/vigilcare-dashboard/src/__tests__/AlertCard.test.js b/vigilcare-dashboard/src/__tests__/AlertCard.test.js index 54b93d6..c72a859 100644 --- a/vigilcare-dashboard/src/__tests__/AlertCard.test.js +++ b/vigilcare-dashboard/src/__tests__/AlertCard.test.js @@ -26,7 +26,7 @@ describe('AlertCard', () => { it('showsAlertTypeAndSeverity', () => { const wrapper = mount(AlertCard, { props: { alert: openAlert } }) expect(wrapper.text()).toContain('Critical') - expect(wrapper.text()).toContain('SEPSIS_WARNING') + expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)') }) it('acknowledgeButtonEmitsEvent', async () => { diff --git a/vigilcare-dashboard/src/__tests__/AlertLabels.test.js b/vigilcare-dashboard/src/__tests__/AlertLabels.test.js new file mode 100644 index 0000000..549ddc3 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/AlertLabels.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { alertTypeLabel, bundleTriggerLabel } from '@/api/normalize' + +describe('AlertLabels', () => { + it('sofaSepsisLabel', () => { + expect(alertTypeLabel('SofaSepsis')).toBe('Sepsis Alert (SOFA)') + }) + + it('legacySepsisLabel', () => { + expect(alertTypeLabel('SepsisWarning')).toBe('Sepsis Warning (SIRS — Legacy)') + }) + + it('qsofaScreenLabel', () => { + expect(alertTypeLabel('QsofaScreen')).toBe('qSOFA Screen') + }) + + it('gcsCriticalLabel', () => { + expect(alertTypeLabel('GcsCritical')).toBe('GCS Critical (≤ 8)') + }) + + it('bundleTriggerSofa', () => { + expect(bundleTriggerLabel('SOFA_SEPSIS')).toBe('SOFA delta ≥ 2') + }) + + it('bundleTriggerLegacy', () => { + expect(bundleTriggerLabel('SEPSIS_WARNING')).toBe('SIRS criteria (Legacy)') + }) +}) \ No newline at end of file diff --git a/vigilcare-dashboard/src/__tests__/AlertReasoning.test.js b/vigilcare-dashboard/src/__tests__/AlertReasoning.test.js index cfec341..f9ef2f1 100644 --- a/vigilcare-dashboard/src/__tests__/AlertReasoning.test.js +++ b/vigilcare-dashboard/src/__tests__/AlertReasoning.test.js @@ -11,11 +11,11 @@ const sepsisAlert = { triggeredAt: '2026-06-19T12:00:00Z', } -const qsofaAlert = { +const qsofaScreenAlert = { id: 'alert-2', - alertType: 'QsofaWarning', + alertType: 'QsofaScreen', severity: 'Warning', - details: 'qSOFA score elevated', + details: 'qSOFA screen positive', triggeredAt: '2026-06-19T12:30:00Z', } @@ -24,16 +24,18 @@ describe('AlertReasoning', () => { setActivePinia(createPinia()) }) - it('showsExplanationForSepsisWarning', () => { + it('showsExplanationForLegacySepsisWarning', () => { const wrapper = mount(AlertReasoning, { props: { alert: sepsisAlert } }) - expect(wrapper.text()).toContain('SIRS / Sepsis Alert') - expect(wrapper.text()).toContain('≥2 of 4 SIRS criteria met') + expect(wrapper.text()).toContain('SIRS / Sepsis Alert (Legacy)') + expect(wrapper.text()).toContain('Historical alert') + expect(wrapper.text()).toContain('SOFA delta') }) - it('showsExplanationForQsofa', () => { - const wrapper = mount(AlertReasoning, { props: { alert: qsofaAlert } }) - expect(wrapper.text()).toContain('qSOFA Alert') - expect(wrapper.text()).toContain('≥2 of 3 qSOFA criteria met') + it('showsExplanationForQsofaScreen', () => { + const wrapper = mount(AlertReasoning, { props: { alert: qsofaScreenAlert } }) + expect(wrapper.text()).toContain('qSOFA Screen') + expect(wrapper.text()).toContain('Bedside screen positive') + expect(wrapper.text()).toContain('Recommend ordering SOFA labs (PaO₂') }) it('showsRawDetailsForUnknownType', () => { @@ -48,4 +50,4 @@ describe('AlertReasoning', () => { expect(wrapper.text()).toContain('CustomUnknown') expect(wrapper.text()).toContain('Something unusual happened') }) -}) +}) \ No newline at end of file diff --git a/vigilcare-dashboard/src/__tests__/FeedbackSummary.test.js b/vigilcare-dashboard/src/__tests__/FeedbackSummary.test.js index fd33996..26b203c 100644 --- a/vigilcare-dashboard/src/__tests__/FeedbackSummary.test.js +++ b/vigilcare-dashboard/src/__tests__/FeedbackSummary.test.js @@ -28,8 +28,8 @@ describe('FeedbackSummary', () => { it('showsPerAlertTypeBreakdown', () => { const wrapper = mount(FeedbackSummary) - expect(wrapper.text()).toContain('SEPSIS_WARNING') - expect(wrapper.text()).toContain('WARNING_HEART_RATE') + expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)') + expect(wrapper.text()).toContain('Warning Heart Rate') expect(wrapper.text()).toContain('2 ratings') expect(wrapper.text()).toContain('1 ratings') }) diff --git a/vigilcare-dashboard/src/__tests__/GcsEntryForm.test.js b/vigilcare-dashboard/src/__tests__/GcsEntryForm.test.js new file mode 100644 index 0000000..0d060b6 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/GcsEntryForm.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import GcsEntryForm from '@/components/patient/GcsEntryForm.vue' + +describe('GcsEntryForm', () => { + it('rendersThreeDropdowns', () => { + const wrapper = mount(GcsEntryForm) + expect(wrapper.findAll('select')).toHaveLength(3) + expect(wrapper.text()).toContain('Eye (E)') + expect(wrapper.text()).toContain('Verbal (V)') + expect(wrapper.text()).toContain('Motor (M)') + }) + + it('computesTotalCorrectly', async () => { + const wrapper = mount(GcsEntryForm) + await wrapper.findAll('select')[0].setValue(2) + await wrapper.findAll('select')[1].setValue(3) + await wrapper.findAll('select')[2].setValue(4) + expect(wrapper.text()).toContain('GCS 9/15') + }) + + it('showsClassification', async () => { + const wrapper = mount(GcsEntryForm) + await wrapper.findAll('select')[0].setValue(2) + await wrapper.findAll('select')[1].setValue(3) + await wrapper.findAll('select')[2].setValue(3) + expect(wrapper.text()).toContain('Severe') + + await wrapper.findAll('select')[0].setValue(4) + await wrapper.findAll('select')[1].setValue(4) + await wrapper.findAll('select')[2].setValue(4) + expect(wrapper.text()).toContain('Moderate') + + await wrapper.findAll('select')[0].setValue(4) + await wrapper.findAll('select')[1].setValue(5) + await wrapper.findAll('select')[2].setValue(6) + expect(wrapper.text()).toContain('Mild') + }) + + it('emitsSubmitWithComponents', async () => { + const wrapper = mount(GcsEntryForm) + await wrapper.findAll('select')[0].setValue(3) + await wrapper.findAll('select')[1].setValue(4) + await wrapper.findAll('select')[2].setValue(5) + await wrapper.find('button').trigger('click') + expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ eye: 3, verbal: 4, motor: 5 }) + }) + + it('defaultsToNormalValues', () => { + const wrapper = mount(GcsEntryForm) + expect(wrapper.text()).toContain('GCS 15/15') + expect(wrapper.text()).toContain('E4 V5 M6') + }) + + it('usesResponsiveLayout', () => { + const wrapper = mount(GcsEntryForm) + const grid = wrapper.find('.grid') + expect(grid.classes()).toContain('grid-cols-1') + expect(grid.classes()).toContain('sm:grid-cols-3') + expect(wrapper.find('button').classes()).toContain('w-full') + expect(wrapper.find('button').classes()).toContain('sm:w-auto') + }) +}) \ No newline at end of file diff --git a/vigilcare-dashboard/src/__tests__/ScoresPanel.test.js b/vigilcare-dashboard/src/__tests__/ScoresPanel.test.js new file mode 100644 index 0000000..c27788d --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/ScoresPanel.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ScoresPanel from '@/components/patient/ScoresPanel.vue' +import { useScoringStore } from '@/stores/scoring' + +describe('ScoresPanel', () => { + beforeEach(() => { + setActivePinia(createPinia()) + const store = useScoringStore() + store.$patch({ + news2: { totalScore: 5, riskLevel: 'Medium' }, + gcs: { eyeScore: 4, verbalScore: 5, motorScore: 6, totalScore: 15 }, + sofa: { totalScore: 4, deltaFromBaseline: 0, respiratoryScore: 1, coagulationScore: 0, liverScore: 0, cardiovascularScore: 1, cnsScore: 1, renalScore: 1 }, + qsofa: { activeCriteria: 2 }, + }) + }) + + it('showsGcsSection', () => { + const wrapper = mount(ScoresPanel) + expect(wrapper.text()).toContain('GCS') + expect(wrapper.text()).toContain('15/15') + expect(wrapper.text()).toContain('E4 V5 M6') + }) + + it('showsSofaSection', () => { + const wrapper = mount(ScoresPanel) + expect(wrapper.text()).toContain('SOFA') + expect(wrapper.text()).toContain('4/24') + }) + + it('noSirsReference', () => { + const wrapper = mount(ScoresPanel) + expect(wrapper.text().toUpperCase()).not.toContain('SIRS') + }) + + it('qsofaLabeledAsScreen', () => { + const wrapper = mount(ScoresPanel) + expect(wrapper.text()).toContain('qSOFA Screen') + expect(wrapper.text()).toContain('Bedside screening') + }) + + it('sofaOrganBadgesUseResponsiveGrid', () => { + const wrapper = mount(ScoresPanel) + const grid = wrapper.find('.grid.grid-cols-2') + expect(grid.exists()).toBe(true) + expect(grid.classes()).toContain('sm:flex') + }) +}) \ No newline at end of file diff --git a/vigilcare-dashboard/src/__tests__/SofaScorePanel.test.js b/vigilcare-dashboard/src/__tests__/SofaScorePanel.test.js new file mode 100644 index 0000000..3c9b2cc --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/SofaScorePanel.test.js @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import SofaScorePanel from '@/components/patient/SofaScorePanel.vue' + +const mockFetch = vi.fn() +const mockState = { + total: null, + delta: null, + organs: [], + hasStaleData: false, + staleness: null, + loading: false, + fetch: mockFetch, +} + +vi.mock('@/composables/useSofa', () => ({ + useSofa: () => mockState, +})) + +describe('SofaScorePanel', () => { + beforeEach(() => { + mockState.total = null + mockState.delta = null + mockState.organs = [] + mockState.hasStaleData = false + mockState.staleness = null + mockState.loading = false + mockFetch.mockClear() + }) + + it('showsLabsPending_WhenNoScore', () => { + const wrapper = mount(SofaScorePanel, { + props: { encounterId: 'enc-1' }, + }) + expect(wrapper.text()).toContain('Labs pending') + }) + + it('showsOrganBreakdown', () => { + mockState.total = 8 + mockState.organs = [ + { name: 'Respiratory', score: 2, max: 4 }, + { name: 'Coagulation', score: 1, max: 4 }, + { name: 'Liver', score: 0, max: 4 }, + { name: 'Cardiovascular', score: 3, max: 4 }, + { name: 'CNS', score: 1, max: 4 }, + { name: 'Renal', score: 1, max: 4 }, + ] + const wrapper = mount(SofaScorePanel, { + props: { encounterId: 'enc-1' }, + }) + expect(wrapper.text()).toContain('Respiratory') + expect(wrapper.text()).toContain('Renal') + expect(wrapper.text()).toContain('8/24') + }) + + it('showsDeltaBadge_WhenDeltaGe2', () => { + mockState.total = 10 + mockState.delta = 2 + mockState.organs = [{ name: 'CNS', score: 2, max: 4 }] + const wrapper = mount(SofaScorePanel, { + props: { encounterId: 'enc-1' }, + }) + expect(wrapper.text()).toContain('+2 from baseline') + }) + + it('showsStalenessWarning', () => { + mockState.total = 6 + mockState.hasStaleData = true + mockState.staleness = { + staleComponents: ['Platelets'], + missingComponents: ['PaO2'], + usedSpO2Fallback: true, + } + mockState.organs = [{ name: 'Coagulation', score: 1, max: 4 }] + const wrapper = mount(SofaScorePanel, { + props: { encounterId: 'enc-1' }, + }) + expect(wrapper.text()).toContain('Missing: PaO2') + expect(wrapper.text()).toContain('Stale: Platelets') + expect(wrapper.text()).toContain('SpO₂/FiO₂ proxy') + }) + + it('organScoreColors', () => { + mockState.total = 3 + mockState.organs = [ + { name: 'Liver', score: 0, max: 4 }, + { name: 'CNS', score: 2, max: 4 }, + { name: 'Renal', score: 4, max: 4 }, + ] + const wrapper = mount(SofaScorePanel, { + props: { encounterId: 'enc-1' }, + }) + const badges = wrapper.findAll('span') + expect(badges.some(b => b.text().includes('0/4'))).toBe(true) + expect(badges.some(b => b.text().includes('2/4'))).toBe(true) + expect(badges.some(b => b.text().includes('4/4'))).toBe(true) + }) + + it('usesResponsiveOrganGrid', () => { + mockState.total = 4 + mockState.organs = [{ name: 'CNS', score: 1, max: 4 }] + const wrapper = mount(SofaScorePanel, { + props: { encounterId: 'enc-1' }, + }) + const grid = wrapper.find('.grid') + expect(grid.classes()).toContain('grid-cols-2') + expect(grid.classes()).toContain('sm:grid-cols-3') + expect(grid.classes()).toContain('lg:grid-cols-6') + }) +}) \ No newline at end of file diff --git a/vigilcare-dashboard/src/api/clinical.js b/vigilcare-dashboard/src/api/clinical.js index 207010b..03162cc 100644 --- a/vigilcare-dashboard/src/api/clinical.js +++ b/vigilcare-dashboard/src/api/clinical.js @@ -8,6 +8,14 @@ export function fetchCurrentQsofa(encounterId) { return api.get(`/api/v1/encounters/${encounterId}/qsofa/current`) } +export function fetchCurrentGcs(encounterId) { + return api.get(`/api/v1/encounters/${encounterId}/gcs`) +} + +export function fetchCurrentSofa(encounterId) { + return api.get(`/api/v1/encounters/${encounterId}/sofa`) +} + export function fetchSepsisBundle(encounterId) { return api.get(`/api/v1/encounters/${encounterId}/sepsis-bundle/current`) } @@ -16,6 +24,35 @@ export function fetchOrders(encounterId) { return api.get(`/api/v1/encounters/${encounterId}/orders`) } +export async function submitGcsObservations(encounterId, eye, verbal, motor) { + const recordedAt = new Date().toISOString() + return api.post(`/api/v1/encounters/${encounterId}/observations`, { + observations: [ + { + observationCode: 'GCS_EYE', + value: eye, + unit: 'score', + source: 'Manual', + recordedAt, + }, + { + observationCode: 'GCS_VERBAL', + value: verbal, + unit: 'score', + source: 'Manual', + recordedAt, + }, + { + observationCode: 'GCS_MOTOR', + value: motor, + unit: 'score', + source: 'Manual', + recordedAt, + }, + ], + }) +} + export async function fetchNews2History(encounterId, { limit = 100 } = {}) { const all = [] let cursor = null diff --git a/vigilcare-dashboard/src/api/normalize.js b/vigilcare-dashboard/src/api/normalize.js index d119993..0134980 100644 --- a/vigilcare-dashboard/src/api/normalize.js +++ b/vigilcare-dashboard/src/api/normalize.js @@ -1,7 +1,60 @@ -// Map API PascalCase alert types to display labels (e.g. WarningHeartRate → WARNING_HEART_RATE) +const ALERT_TYPE_LABELS = { + SepsisWarning: 'Sepsis Warning (SIRS — Legacy)', + QsofaWarning: 'qSOFA Alert (Legacy)', + QsofaScreen: 'qSOFA Screen', + SofaSepsis: 'Sepsis Alert (SOFA)', + SofaWarning: 'SOFA Warning', + GcsCritical: 'GCS Critical (≤ 8)', + GcsWarning: 'GCS Warning (9–12)', + News2Warning: 'NEWS2 Warning', + News2Emergency: 'NEWS2 Emergency', + RapidDeterioration: 'Rapid Deterioration', + CriticalHeartRate: 'Critical Heart Rate', + CriticalTempC: 'Critical Temperature', + CriticalPotassiumMeqL: 'Critical Potassium', + CriticalSpo2: 'Critical SpO₂', + CriticalRespRate: 'Critical Respiratory Rate', + CriticalWbcKUl: 'Critical WBC', + CriticalSystolicBp: 'Critical Systolic BP', + CriticalDiastolicBp: 'Critical Diastolic BP', + CriticalLactateMmolL: 'Critical Lactate', + CriticalAvpu: 'Critical AVPU', + CriticalGlucoseMgDl: 'Critical Glucose', + WarningHeartRate: 'Warning Heart Rate', + WarningTempC: 'Warning Temperature', + WarningPotassiumMeqL: 'Warning Potassium', + WarningSpo2: 'Warning SpO₂', + WarningRespRate: 'Warning Respiratory Rate', + WarningWbcKUl: 'Warning WBC', + WarningSystolicBp: 'Warning Systolic BP', + WarningDiastolicBp: 'Warning Diastolic BP', + WarningLactateMmolL: 'Warning Lactate', + WarningGlucoseMgDl: 'Warning Glucose', + CriticalPao2MmHg: 'Critical PaO₂', + WarningPao2MmHg: 'Warning PaO₂', + CriticalPlateletKUl: 'Critical Platelets', + WarningPlateletKUl: 'Warning Platelets', + CriticalBilirubinMgDl: 'Critical Bilirubin', + WarningBilirubinMgDl: 'Warning Bilirubin', + CriticalCreatinineMgDl: 'Critical Creatinine', + WarningCreatinineMgDl: 'Warning Creatinine', +} + +const BUNDLE_TRIGGER_LABELS = { + SOFA_SEPSIS: 'SOFA delta ≥ 2', + SEPSIS_WARNING: 'SIRS criteria (Legacy)', + QSOFA_WARNING: 'qSOFA alert (Legacy)', +} + export function alertTypeLabel(type) { if (!type) return '' - return type.replace(/([A-Z])/g, '_$1').slice(1).toUpperCase() + return ALERT_TYPE_LABELS[type] + ?? type.replace(/([A-Z])/g, '_$1').slice(1).toUpperCase() +} + +export function bundleTriggerLabel(triggerType) { + if (!triggerType) return '' + return BUNDLE_TRIGGER_LABELS[triggerType] ?? triggerType.replace(/_/g, ' ') } export function alertStatusToApiFilter(status) { @@ -23,6 +76,9 @@ const OBSERVATION_LABELS = { AVPU: 'AVPU', SUPPLEMENTAL_O2: 'Supplemental O₂', WBC_K_UL: 'WBC', + GCS_EYE: 'GCS Eye', + GCS_VERBAL: 'GCS Verbal', + GCS_MOTOR: 'GCS Motor', } export function observationCodeLabel(code) { diff --git a/vigilcare-dashboard/src/components/alerts/AlertCard.vue b/vigilcare-dashboard/src/components/alerts/AlertCard.vue index 73473b7..e4c26e5 100644 --- a/vigilcare-dashboard/src/components/alerts/AlertCard.vue +++ b/vigilcare-dashboard/src/components/alerts/AlertCard.vue @@ -1,16 +1,27 @@ + + \ No newline at end of file diff --git a/vigilcare-dashboard/src/components/patient/ScoresPanel.vue b/vigilcare-dashboard/src/components/patient/ScoresPanel.vue index ddfcc5a..43379f4 100644 --- a/vigilcare-dashboard/src/components/patient/ScoresPanel.vue +++ b/vigilcare-dashboard/src/components/patient/ScoresPanel.vue @@ -1,40 +1,38 @@ -
-
-
NEWS2
-
+
+ +
+
NEWS2
+
{{ news2?.totalScore ?? '—' }} {{ news2.riskLevel }}
-
-
-
qSOFA
-
- {{ qsofa?.activeCriteria ?? '—' }} - / 3 criteria + + + +
+
+
GCS
+
-
+
+ + {{ gcsTotal }}/15 + + + {{ gcsClassificationDisplay.label }} + + + E{{ gcsComponents.eye }} V{{ gcsComponents.verbal }} M{{ gcsComponents.motor }} + +
+
No GCS recorded
+ + + + + + +
+
SOFA
+
+
+ + {{ sofaTotal }}/24 + + + Δ +{{ sofaDelta }} + + Δ +1 +
+
+ + {{ organ.name }} {{ organ.score }} + +
+
+
Labs pending
+
+ + +
+
qSOFA Screen
+

+ Bedside screening — qSOFA ≥ 2 suggests ordering SOFA labs +

+
+ + {{ qsofa?.activeCriteria ?? '—' }} + + / 3 criteria +
+
+ + \ No newline at end of file diff --git a/vigilcare-dashboard/src/components/patient/SepsisBundlePanel.vue b/vigilcare-dashboard/src/components/patient/SepsisBundlePanel.vue index 4468f94..f40efaf 100644 --- a/vigilcare-dashboard/src/components/patient/SepsisBundlePanel.vue +++ b/vigilcare-dashboard/src/components/patient/SepsisBundlePanel.vue @@ -2,10 +2,12 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import Card from '@/components/ui/Card.vue' import Badge from '@/components/ui/Badge.vue' -import { bundleElementLabel } from '@/api/normalize' +import { bundleElementLabel, bundleTriggerLabel } from '@/api/normalize' const props = defineProps({ - bundle: { type: Object, required: true }, + bundle: { type: Object, default: null }, + qsofa: { type: Object, default: null }, + sofa: { type: Object, default: null }, }) const now = ref(Date.now()) @@ -21,7 +23,17 @@ onBeforeUnmount(() => { if (timer) clearInterval(timer) }) +const showQsofaScreenMessage = computed(() => { + if (props.bundle) return false + return (props.qsofa?.activeCriteria ?? 0) >= 2 +}) + +const triggerLabel = computed(() => + bundleTriggerLabel(props.bundle?.triggeringAlertType), +) + const remainingMs = computed(() => { + if (!props.bundle?.deadlineAt) return 0 const deadline = new Date(props.bundle.deadlineAt).getTime() return Math.max(0, deadline - now.value) }) @@ -34,7 +46,7 @@ const countdown = computed(() => { }) const complianceVariant = computed(() => { - const status = props.bundle.complianceStatus + const status = props.bundle?.complianceStatus if (status === 'Compliant') return 'success' if (status === 'NonCompliant') return 'critical' return 'warning' @@ -52,43 +64,72 @@ function elementComplete(element) {

Sepsis Bundle

- {{ bundle.complianceStatus }} + {{ bundle.complianceStatus }}
-
- Time to deadline - - {{ countdown }} - +
+ qSOFA screen positive — order SOFA labs to evaluate organ dysfunction.
-
    -
  • + Current SOFA: + {{ sofa.totalScore }}/24 + + Δ +{{ sofa.deltaFromBaseline }} + +
+ + + +

+ No active sepsis bundle for this encounter. +

- + \ No newline at end of file diff --git a/vigilcare-dashboard/src/components/patient/SofaScorePanel.vue b/vigilcare-dashboard/src/components/patient/SofaScorePanel.vue new file mode 100644 index 0000000..239e42f --- /dev/null +++ b/vigilcare-dashboard/src/components/patient/SofaScorePanel.vue @@ -0,0 +1,78 @@ + + + \ No newline at end of file diff --git a/vigilcare-dashboard/src/composables/useGcs.js b/vigilcare-dashboard/src/composables/useGcs.js new file mode 100644 index 0000000..0760bd2 --- /dev/null +++ b/vigilcare-dashboard/src/composables/useGcs.js @@ -0,0 +1,87 @@ +import { ref, computed, unref, watch } from 'vue' +import * as clinicalApi from '@/api/clinical' + +function gcsClassification(total) { + if (total <= 8) return { label: 'Severe', variant: 'critical' } + if (total <= 12) return { label: 'Moderate', variant: 'warning' } + return { label: 'Mild', variant: 'success' } +} + +export function useGcs(encounterId) { + const gcs = ref(null) + const loading = ref(false) + const submitting = ref(false) + const error = ref(null) + + async function fetch() { + const id = unref(encounterId) + if (!id) return + loading.value = true + error.value = null + try { + gcs.value = await clinicalApi.fetchCurrentGcs(id) + } catch (e) { + gcs.value = null + error.value = e.message + } finally { + loading.value = false + } + } + + async function submit(eye, verbal, motor) { + const id = unref(encounterId) + if (!id) return + + const previous = gcs.value + const optimisticTotal = eye + verbal + motor + gcs.value = { + eyeScore: eye, + verbalScore: verbal, + motorScore: motor, + totalScore: optimisticTotal, + classification: gcsClassification(optimisticTotal).label.toUpperCase(), + calculatedAt: new Date().toISOString(), + } + + submitting.value = true + error.value = null + try { + await clinicalApi.submitGcsObservations(id, eye, verbal, motor) + await fetch() + } catch (e) { + gcs.value = previous + error.value = e.message + throw e + } finally { + submitting.value = false + } + } + + const total = computed(() => gcs.value?.totalScore ?? null) + const components = computed(() => { + if (!gcs.value) return null + return { + eye: gcs.value.eyeScore, + verbal: gcs.value.verbalScore, + motor: gcs.value.motorScore, + } + }) + const classification = computed(() => { + if (total.value == null) return null + return gcsClassification(total.value) + }) + + watch(() => unref(encounterId), fetch, { immediate: true }) + + return { + gcs, + loading, + submitting, + error, + total, + components, + classification, + fetch, + submit, + } +} \ No newline at end of file diff --git a/vigilcare-dashboard/src/composables/useSofa.js b/vigilcare-dashboard/src/composables/useSofa.js new file mode 100644 index 0000000..1a7da66 --- /dev/null +++ b/vigilcare-dashboard/src/composables/useSofa.js @@ -0,0 +1,67 @@ +import { ref, computed, unref, watch } from 'vue' +import * as clinicalApi from '@/api/clinical' + +const ORGAN_FIELDS = [ + { name: 'Respiratory', key: 'respiratoryScore' }, + { name: 'Coagulation', key: 'coagulationScore' }, + { name: 'Liver', key: 'liverScore' }, + { name: 'Cardiovascular', key: 'cardiovascularScore' }, + { name: 'CNS', key: 'cnsScore' }, + { name: 'Renal', key: 'renalScore' }, +] + +export function useSofa(encounterId) { + const sofa = ref(null) + const loading = ref(false) + const error = ref(null) + + async function fetch() { + const id = unref(encounterId) + if (!id) return + loading.value = true + error.value = null + try { + sofa.value = await clinicalApi.fetchCurrentSofa(id) + } catch (e) { + sofa.value = null + error.value = e.message + } finally { + loading.value = false + } + } + + const total = computed(() => sofa.value?.totalScore ?? null) + const delta = computed(() => sofa.value?.deltaFromBaseline ?? null) + const isBaseline = computed(() => sofa.value?.isBaseline ?? false) + const organs = computed(() => { + if (!sofa.value) return [] + return ORGAN_FIELDS.map(({ name, key }) => ({ + name, + score: sofa.value[key] ?? 0, + max: 4, + })) + }) + const staleness = computed(() => sofa.value?.staleness ?? null) + const hasStaleData = computed(() => { + const s = staleness.value + if (!s) return false + return (s.staleComponents?.length ?? 0) > 0 + || (s.missingComponents?.length ?? 0) > 0 + || s.usedSpO2Fallback + }) + + watch(() => unref(encounterId), fetch, { immediate: true }) + + return { + sofa, + loading, + error, + total, + delta, + isBaseline, + organs, + staleness, + hasStaleData, + fetch, + } +} \ No newline at end of file diff --git a/vigilcare-dashboard/src/stores/scoring.js b/vigilcare-dashboard/src/stores/scoring.js new file mode 100644 index 0000000..f5e6be7 --- /dev/null +++ b/vigilcare-dashboard/src/stores/scoring.js @@ -0,0 +1,130 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as clinicalApi from '@/api/clinical' + +const POLL_MS = 15_000 + +function gcsClassification(total) { + if (total <= 8) return { label: 'Severe', variant: 'critical' } + if (total <= 12) return { label: 'Moderate', variant: 'warning' } + return { label: 'Mild', variant: 'success' } +} + +export const useScoringStore = defineStore('scoring', () => { + const encounterId = ref(null) + const gcs = ref(null) + const sofa = ref(null) + const news2 = ref(null) + const qsofa = ref(null) + const loading = ref(false) + const submittingGcs = ref(false) + let timer = null + + const gcsTotal = computed(() => gcs.value?.totalScore ?? null) + const gcsComponents = computed(() => { + if (!gcs.value) return null + return { + eye: gcs.value.eyeScore, + verbal: gcs.value.verbalScore, + motor: gcs.value.motorScore, + } + }) + const gcsClassificationDisplay = computed(() => { + if (gcsTotal.value == null) return null + return gcsClassification(gcsTotal.value) + }) + + const sofaTotal = computed(() => sofa.value?.totalScore ?? null) + const sofaDelta = computed(() => sofa.value?.deltaFromBaseline ?? null) + const sofaOrgans = computed(() => { + if (!sofa.value) return [] + return [ + { name: 'Resp', score: sofa.value.respiratoryScore ?? 0 }, + { name: 'Coag', score: sofa.value.coagulationScore ?? 0 }, + { name: 'Liver', score: sofa.value.liverScore ?? 0 }, + { name: 'CV', score: sofa.value.cardiovascularScore ?? 0 }, + { name: 'CNS', score: sofa.value.cnsScore ?? 0 }, + { name: 'Renal', score: sofa.value.renalScore ?? 0 }, + ] + }) + + const news2Variant = computed(() => { + const score = news2.value?.totalScore ?? 0 + if (score >= 7 || news2.value?.hasSingleParamThree) return 'critical' + if (score >= 5) return 'warning' + return 'success' + }) + + const qsofaVariant = computed(() => { + const count = qsofa.value?.activeCriteria ?? 0 + if (count >= 2) return 'warning' + if (count === 1) return 'warning' + return 'success' + }) + + async function refresh() { + if (!encounterId.value) return + loading.value = true + const id = encounterId.value + try { + const [g, s, n, q] = await Promise.all([ + clinicalApi.fetchCurrentGcs(id).catch(() => null), + clinicalApi.fetchCurrentSofa(id).catch(() => null), + clinicalApi.fetchCurrentNews2(id).catch(() => null), + clinicalApi.fetchCurrentQsofa(id).catch(() => null), + ]) + gcs.value = g + sofa.value = s + news2.value = n + qsofa.value = q + } finally { + loading.value = false + } + } + + async function submitGcs(eye, verbal, motor) { + if (!encounterId.value) return + submittingGcs.value = true + try { + await clinicalApi.submitGcsObservations(encounterId.value, eye, verbal, motor) + await refresh() + } finally { + submittingGcs.value = false + } + } + + function startPolling(id) { + stopPolling() + encounterId.value = id + refresh() + timer = setInterval(refresh, POLL_MS) + } + + function stopPolling() { + if (timer) clearInterval(timer) + timer = null + encounterId.value = null + } + + return { + encounterId, + gcs, + sofa, + news2, + qsofa, + loading, + submittingGcs, + gcsTotal, + gcsComponents, + gcsClassificationDisplay, + sofaTotal, + sofaDelta, + sofaOrgans, + news2Variant, + qsofaVariant, + refresh, + submitGcs, + startPolling, + stopPolling, + } +}) \ No newline at end of file diff --git a/vigilcare-dashboard/src/views/PatientDetail.vue b/vigilcare-dashboard/src/views/PatientDetail.vue index 80ba51d..9694cc3 100644 --- a/vigilcare-dashboard/src/views/PatientDetail.vue +++ b/vigilcare-dashboard/src/views/PatientDetail.vue @@ -5,10 +5,12 @@ import { storeToRefs } from 'pinia' import { usePolling } from '@/composables/usePolling' import { useReplayControls } from '@/composables/useReplayControls' import { useAlertStore } from '@/stores/alerts' +import { useScoringStore } from '@/stores/scoring' import * as encountersApi from '@/api/encounters' import * as clinicalApi from '@/api/clinical' import VitalsPanel from '@/components/patient/VitalsPanel.vue' import ScoresPanel from '@/components/patient/ScoresPanel.vue' +import SofaScorePanel from '@/components/patient/SofaScorePanel.vue' import AlertsList from '@/components/patient/AlertsList.vue' import OrdersPanel from '@/components/patient/OrdersPanel.vue' import SepsisBundlePanel from '@/components/patient/SepsisBundlePanel.vue' @@ -20,7 +22,9 @@ import Skeleton from '@/components/ui/Skeleton.vue' const route = useRoute() const alertStore = useAlertStore() +const scoringStore = useScoringStore() const { alerts } = storeToRefs(alertStore) +const { qsofa, sofa } = storeToRefs(scoringStore) const { isPaused, speed, @@ -42,7 +46,6 @@ const { const encounter = ref(null) const loading = ref(true) const observations = ref([]) -const news2 = ref(null) const news2History = ref([]) const medications = ref([]) const sepsisBundle = ref(null) @@ -85,10 +88,9 @@ async function loadAll() { const id = route.params.encounterId loading.value = true try { - const [enc, obs, n2, history, meds, bundle, ord] = await Promise.all([ + const [enc, obs, history, meds, bundle, ord] = await Promise.all([ encountersApi.fetchEncounter(id), encountersApi.fetchObservations(id), - clinicalApi.fetchCurrentNews2(id).catch(() => null), clinicalApi.fetchNews2History(id).catch(() => []), clinicalApi.fetchMedications(id).catch(() => []), clinicalApi.fetchSepsisBundle(id).catch(() => null), @@ -97,11 +99,11 @@ async function loadAll() { await alertStore.loadAlerts(id) encounter.value = enc observations.value = obs - news2.value = n2 news2History.value = history medications.value = meds sepsisBundle.value = bundle orders.value = ord.items ?? ord + scoringStore.startPolling(id) syncReplayBounds() } finally { loading.value = false @@ -134,6 +136,7 @@ watch(() => route.params.encounterId, () => { selectedAlert.value = null nextAlertIndex = 0 pause() + scoringStore.stopPolling() loadAll() }) @@ -146,7 +149,10 @@ watch(openAlerts, (list) => { watch([observations, news2History, alerts], syncReplayBounds, { deep: true }) -onBeforeUnmount(() => stopPlayback()) +onBeforeUnmount(() => { + stopPlayback() + scoringStore.stopPolling() +}) + \ No newline at end of file