feature: Sepsis Engine Refactor: Remove SIRS, Rewire qSOFA + Bundle

Frontend: GCS Entry, SOFA Display, Sepsis UI Refactor
This commit is contained in:
voltsrage
2026-06-21 03:56:27 +08:00
parent 93ea473d2b
commit bf46e6554a
48 changed files with 2686 additions and 714 deletions
@@ -0,0 +1,19 @@
using FluentAssertions;
public class AlertCreationGuardTests
{
[Fact]
public void CannotCreateNewSepsisWarning()
{
var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SepsisWarning);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*deprecated*");
}
[Fact]
public void AllowsSofaSepsis()
{
var act = () => AlertCreationGuard.EnsureAllowed(AlertType.SofaSepsis);
act.Should().NotThrow();
}
}
@@ -15,7 +15,7 @@ public class ObservabilityPhase8Tests
"alerts_unacknowledged_gauge", "alerts_unacknowledged_gauge",
"kafka_consumer_lag", "kafka_consumer_lag",
"outbox_pending_events", "outbox_pending_events",
"sirs_detections_total", "qsofa_detections_total",
"escalations_total", "escalations_total",
}; };
@@ -80,12 +80,12 @@ public class QsofaDetectorTests : IAsyncLifetime
r2.Outcome.Should().Be(QsofaOutcome.AlertCreated); r2.Outcome.Should().Be(QsofaOutcome.AlertCreated);
var alert = await db.ClinicalAlerts.SingleAsync(); var alert = await db.ClinicalAlerts.SingleAsync();
alert.AlertType.Should().Be(AlertType.QsofaWarning); alert.AlertType.Should().Be(AlertType.QsofaScreen);
alert.Severity.Should().Be(AlertSeverity.Critical); alert.Severity.Should().Be(AlertSeverity.Warning);
alert.Status.Should().Be(AlertStatus.Open); alert.Status.Should().Be(AlertStatus.Open);
alert.EncounterId.Should().Be(_encounterId); alert.EncounterId.Should().Be(_encounterId);
alert.PatientId.Should().Be(_patientId); 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"); var outbox = await db.OutboxEvents.SingleAsync(e => e.Topic == "alert.generated");
outbox.PartitionKey.Should().Be(_encounterId.ToString()); outbox.PartitionKey.Should().Be(_encounterId.ToString());
@@ -138,7 +138,7 @@ public class QsofaDetectorTests : IAsyncLifetime
r3.Outcome.Should().Be(QsofaOutcome.AlertAlreadyOpen); r3.Outcome.Should().Be(QsofaOutcome.AlertAlreadyOpen);
var alertCount = await db.ClinicalAlerts.CountAsync(); 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"); var outboxCount = await db.OutboxEvents.CountAsync(e => e.Topic == "alert.generated");
outboxCount.Should().Be(1, "outbox event must be written exactly once"); outboxCount.Should().Be(1, "outbox event must be written exactly once");
+54 -38
View File
@@ -43,28 +43,47 @@ public class SepsisBundleTests : IAsyncLifetime
var cache = redis.GetDatabase(); var cache = redis.GetDatabase();
foreach (var key in QsofaCalculator.AllCriterionKeys(_encounterId)) foreach (var key in QsofaCalculator.AllCriterionKeys(_encounterId))
await cache.KeyDeleteAsync(key); await cache.KeyDeleteAsync(key);
foreach (var key in SirsEvaluator.AllCriterionKeys(_encounterId))
await cache.KeyDeleteAsync(key);
} }
public Task DisposeAsync() => Task.CompletedTask; public Task DisposeAsync() => Task.CompletedTask;
[Fact] private async Task CreateSofaSepsisBundleAsync(IServiceScope scope)
public async Task QsofaAlert_CreatesBundleWithFourOrders()
{ {
using var scope = _fixture.Services.CreateScope(); var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); var alertId = Guid.NewGuid();
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m); 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<AppDbContext>();
await CreateSofaSepsisBundleAsync(scope);
var bundle = await db.SepsisBundles var bundle = await db.SepsisBundles
.Include(b => b.Elements) .Include(b => b.Elements)
.SingleAsync(b => b.EncounterId == _encounterId); .SingleAsync(b => b.EncounterId == _encounterId);
bundle.ComplianceStatus.Should().Be(SepsisBundleComplianceStatus.InProgress); 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.DeadlineAt.Should().BeCloseTo(bundle.RecognizedAt.AddHours(1), TimeSpan.FromSeconds(5));
bundle.Elements.Should().HaveCount(4); bundle.Elements.Should().HaveCount(4);
@@ -86,40 +105,45 @@ public class SepsisBundleTests : IAsyncLifetime
} }
[Fact] [Fact]
public async Task SirsAlert_CreatesBundle() public async Task QsofaScreen_DoesNotTriggerBundle()
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<SirsDetector>(); var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await detector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m);
await detector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
var bundle = await db.SepsisBundles (await db.SepsisBundles.CountAsync()).Should().Be(0);
.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);
} }
[Fact] [Fact]
public async Task SecondAlert_IdempotentBundle() public async Task SecondAlert_IdempotentBundle()
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();
var qsofaDetector = scope.ServiceProvider.GetRequiredService<QsofaDetector>(); var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
var sirsDetector = scope.ServiceProvider.GetRequiredService<SirsDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await qsofaDetector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); await CreateSofaSepsisBundleAsync(scope);
await qsofaDetector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
var bundleCountAfterFirst = await db.SepsisBundles.CountAsync(b => b.EncounterId == _encounterId); var bundleCountAfterFirst = await db.SepsisBundles.CountAsync(b => b.EncounterId == _encounterId);
bundleCountAfterFirst.Should().Be(1); bundleCountAfterFirst.Should().Be(1);
await sirsDetector.ProcessObservationAsync(_encounterId, _patientId, "HEART_RATE", 95m); var secondAlertId = Guid.NewGuid();
await sirsDetector.ProcessObservationAsync(_encounterId, _patientId, "TEMP_C", 38.5m); 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); 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"); 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() public async Task OrderResulted_CompletesElement()
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var bundleService = scope.ServiceProvider.GetRequiredService<ISepsisBundleService>(); var bundleService = scope.ServiceProvider.GetRequiredService<ISepsisBundleService>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); await CreateSofaSepsisBundleAsync(scope);
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
var lactateElement = await db.SepsisBundleElements var lactateElement = await db.SepsisBundleElements
.Include(e => e.Order) .Include(e => e.Order)
@@ -161,12 +183,10 @@ public class SepsisBundleTests : IAsyncLifetime
public async Task AllElementsResulted_BundleCompliant() public async Task AllElementsResulted_BundleCompliant()
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var bundleService = scope.ServiceProvider.GetRequiredService<ISepsisBundleService>(); var bundleService = scope.ServiceProvider.GetRequiredService<ISepsisBundleService>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); await CreateSofaSepsisBundleAsync(scope);
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
var elements = await db.SepsisBundleElements.ToListAsync(); var elements = await db.SepsisBundleElements.ToListAsync();
foreach (var element in elements) foreach (var element in elements)
@@ -188,12 +208,10 @@ public class SepsisBundleTests : IAsyncLifetime
public async Task DeadlinePassed_CompletionMarksNonCompliant() public async Task DeadlinePassed_CompletionMarksNonCompliant()
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var bundleService = scope.ServiceProvider.GetRequiredService<ISepsisBundleService>(); var bundleService = scope.ServiceProvider.GetRequiredService<ISepsisBundleService>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); await CreateSofaSepsisBundleAsync(scope);
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
var bundle = await db.SepsisBundles.SingleAsync(); var bundle = await db.SepsisBundles.SingleAsync();
bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1); bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1);
@@ -212,11 +230,9 @@ public class SepsisBundleTests : IAsyncLifetime
public async Task DeadlinePassed_MonitorMarksNonCompliant() public async Task DeadlinePassed_MonitorMarksNonCompliant()
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m); await CreateSofaSepsisBundleAsync(scope);
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
var bundle = await db.SepsisBundles.SingleAsync(); var bundle = await db.SepsisBundles.SingleAsync();
bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1); bundle.DeadlineAt = DateTimeOffset.UtcNow.AddHours(-1);
@@ -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<AppDbContext>();
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<QsofaDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<SepsisAlertHandler>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<QsofaDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<QsofaDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<AppDbContext>();
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
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<InvalidOperationException>()
.WithMessage("*deprecated*");
}
[Fact]
public async Task SepsisBundleCompliance_StillTracked()
{
using var scope = _fixture.Services.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<ClinicalMetrics>(),
_fixture.Services.GetRequiredService<ILogger<SepsisBundleMonitorService>>());
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<QsofaDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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");
}
}
@@ -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<AppDbContext>();
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<IConnectionMultiplexer>();
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<SirsDetector>();
}
// 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<SirsDetector>();
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<IConnectionMultiplexer>();
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<SirsDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 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<SirsDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<SirsDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
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<SirsDetector>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<SirsDetector>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
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();
}
}
@@ -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();
}
}
@@ -34,13 +34,10 @@ public class SepsisEngineService : BackgroundService
}; };
using var consumer = new ConsumerBuilder<string, string>(config).Build(); using var consumer = new ConsumerBuilder<string, string>(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); 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 try
{ {
@@ -54,19 +51,9 @@ public class SepsisEngineService : BackgroundService
var evt = JsonSerializer.Deserialize<SepsisObservationEvent>( var evt = JsonSerializer.Deserialize<SepsisObservationEvent>(
result.Message.Value, EventJsonOptions)!; 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(); using var scope = _services.CreateScope();
var sirsDetector = scope.ServiceProvider.GetRequiredService<SirsDetector>();
var qsofaDetector = scope.ServiceProvider.GetRequiredService<QsofaDetector>(); var qsofaDetector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var sirsOutcome = await sirsDetector.ProcessObservationAsync(
evt.EncounterId,
evt.PatientId,
evt.ObservationCode,
evt.Value,
stoppingToken);
var qsofaOutcome = await qsofaDetector.ProcessObservationAsync( var qsofaOutcome = await qsofaDetector.ProcessObservationAsync(
evt.EncounterId, evt.EncounterId,
evt.PatientId, evt.PatientId,
@@ -74,19 +61,12 @@ public class SepsisEngineService : BackgroundService
evt.Value, evt.Value,
stoppingToken); 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) if (qsofaOutcome.Outcome == QsofaOutcome.AlertCreated)
_logger.LogWarning( _logger.LogInformation(
"QSOFA_WARNING created via SepsisEngine " + "QSOFA_SCREEN created via SepsisEngine " +
"— encounter={EncounterId} code={Code} value={Value}", "— encounter={EncounterId} code={Code} value={Value}",
evt.EncounterId, evt.ObservationCode, evt.Value); evt.EncounterId, evt.ObservationCode, evt.Value);
// Commit only after successful processing.
consumer.Commit(result); consumer.Commit(result);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -98,8 +78,6 @@ public class SepsisEngineService : BackgroundService
_logger.LogError(ex, _logger.LogError(ex,
"SepsisEngine failed on topic={Topic} offset={Offset} — not committing", "SepsisEngine failed on topic={Topic} offset={Offset} — not committing",
result?.Topic, result?.Offset.Value); 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); await Task.Delay(2000, stoppingToken);
} }
} }
@@ -18,13 +18,18 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
"'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " + "'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL', " +
"'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " + "'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L', " +
"'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL', " + "'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_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L', " +
"'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " + "'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL', " +
"'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " + "'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L', " +
"'WARNING_GLUCOSE_MG_DL', " + "'WARNING_GLUCOSE_MG_DL', " +
"'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL', " +
"'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL', " +
"'NEWS2_WARNING', 'NEWS2_EMERGENCY', " + "'NEWS2_WARNING', 'NEWS2_EMERGENCY', " +
"'RAPID_DETERIORATION', 'QSOFA_WARNING', " + "'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', " +
"'GCS_CRITICAL', 'GCS_WARNING')"); "'GCS_CRITICAL', 'GCS_WARNING', " +
"'SOFA_SEPSIS', 'SOFA_WARNING')");
}); });
builder.HasKey(a => a.Id); builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
@@ -1,5 +1,6 @@
public enum AlertType public enum AlertType
{ {
[Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")]
SepsisWarning, SepsisWarning,
CriticalHeartRate, CriticalHeartRate,
CriticalTempC, CriticalTempC,
@@ -30,8 +31,11 @@ public enum AlertType
RapidDeterioration, RapidDeterioration,
[Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")]
QsofaWarning, QsofaWarning,
QsofaScreen,
GcsCritical, GcsCritical,
GcsWarning, GcsWarning,
@@ -90,6 +94,7 @@ public static class AlertTypeExtensions
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL", AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
AlertType.SofaSepsis => "SOFA_SEPSIS", AlertType.SofaSepsis => "SOFA_SEPSIS",
AlertType.SofaWarning => "SOFA_WARNING", AlertType.SofaWarning => "SOFA_WARNING",
AlertType.QsofaScreen => "QSOFA_SCREEN",
_ => throw new ArgumentOutOfRangeException(nameof(t)) _ => throw new ArgumentOutOfRangeException(nameof(t))
}; };
@@ -121,6 +126,7 @@ public static class AlertTypeExtensions
"NEWS2_EMERGENCY" => AlertType.News2Emergency, "NEWS2_EMERGENCY" => AlertType.News2Emergency,
"RAPID_DETERIORATION" => AlertType.RapidDeterioration, "RAPID_DETERIORATION" => AlertType.RapidDeterioration,
"QSOFA_WARNING" => AlertType.QsofaWarning, "QSOFA_WARNING" => AlertType.QsofaWarning,
"QSOFA_SCREEN" => AlertType.QsofaScreen,
"GCS_CRITICAL" => AlertType.GcsCritical, "GCS_CRITICAL" => AlertType.GcsCritical,
"GCS_WARNING" => AlertType.GcsWarning, "GCS_WARNING" => AlertType.GcsWarning,
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg, "CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
@@ -189,7 +195,7 @@ public static class AlertTypeExtensions
AlertType.RapidDeterioration => false, AlertType.RapidDeterioration => false,
AlertType.GcsCritical => false, // trajectory alerts are never suppressed AlertType.GcsCritical => false, // trajectory alerts are never suppressed
AlertType.SofaSepsis => false, 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 public static string? ObservationCodeForWarning(this AlertType t) => t switch
+2
View File
@@ -141,6 +141,8 @@ public class GcsDetector
} }
} }
AlertCreationGuard.EnsureAllowed(alertType);
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddQsofaScreenAlertTypecs : Migration
{
/// <inheritdoc />
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'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -156,7 +156,7 @@ namespace VigilCareClinicalAPI.Migrations
b.ToTable("clinical_alerts", null, t => 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')"); t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
@@ -1,7 +0,0 @@
public enum SirsOutcome
{
NotSirsCode,
InsufficientCriteria,
AlertCreated,
AlertAlreadyOpen
}
@@ -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);
}
@@ -199,6 +199,8 @@ public class News2Detector
} }
} }
AlertCreationGuard.EnsureAllowed(alertType);
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -11,19 +11,12 @@ public sealed class ClinicalMetrics
"Total observations ingested, labeled by observation code and source.", "Total observations ingested, labeled by observation code and source.",
labelNames: new[] { "observation_code", "source" }); labelNames: new[] { "observation_code", "source" });
// Labeled by alert_type (THRESHOLD_BREACH, SEPSIS_WARNING, QSOFA_WARNING) and severity // Labeled by alert_type and severity (e.g. QSOFA_SCREEN/WARNING, SOFA_SEPSIS/CRITICAL).
// (Critical, Warning) so the dashboard can show Critical vs Warning rates separately.
public readonly Counter ClinicalAlertsTotal = Metrics.CreateCounter( public readonly Counter ClinicalAlertsTotal = Metrics.CreateCounter(
"clinical_alerts_total", "clinical_alerts_total",
"Total clinical alerts generated, labeled by type and severity.", "Total clinical alerts generated, labeled by type and severity.",
labelNames: new[] { "alert_type", "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( public readonly Counter News2ScoresTotal = Metrics.CreateCounter(
"news2_scores_total", "news2_scores_total",
"Total NEWS2 scores computed, labeled by risk level.", "Total NEWS2 scores computed, labeled by risk level.",
@@ -48,7 +41,7 @@ public sealed class ClinicalMetrics
public readonly Counter QsofaDetectionsTotal = Metrics.CreateCounter( public readonly Counter QsofaDetectionsTotal = Metrics.CreateCounter(
"qsofa_detections_total", "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( public readonly Counter SepsisBundleComplianceTotal = Metrics.CreateCounter(
"sepsis_bundle_compliance_total", "sepsis_bundle_compliance_total",
-1
View File
@@ -105,7 +105,6 @@ try
builder.Services.AddScoped<IQsofaService, QsofaService>(); builder.Services.AddScoped<IQsofaService, QsofaService>();
builder.Services.AddScoped<ISepsisBundleService, SepsisBundleService>(); builder.Services.AddScoped<ISepsisBundleService, SepsisBundleService>();
builder.Services.AddScoped<SepsisAlertHandler>(); builder.Services.AddScoped<SepsisAlertHandler>();
builder.Services.AddScoped<SirsDetector>();
builder.Services.AddScoped<QsofaDetector>(); builder.Services.AddScoped<QsofaDetector>();
builder.Services.AddScoped<UnacknowledgedAlertsCheck>(); builder.Services.AddScoped<UnacknowledgedAlertsCheck>();
builder.Services.AddScoped<PendingOrdersCheck>(); builder.Services.AddScoped<PendingOrdersCheck>();
@@ -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.");
}
}
@@ -1,5 +1,9 @@
using StackExchange.Redis; 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 class QsofaCalculator
{ {
public static readonly IReadOnlyList<string> QsofaCodes = new[] public static readonly IReadOnlyList<string> QsofaCodes = new[]
+27 -42
View File
@@ -5,7 +5,6 @@ using StackExchange.Redis;
public class QsofaDetector public class QsofaDetector
{ {
// 30 minutes in seconds — same sliding window as SIRS.
private const int QsofaTtlSeconds = 1800; private const int QsofaTtlSeconds = 1800;
private readonly IConnectionMultiplexer _redis; private readonly IConnectionMultiplexer _redis;
@@ -42,30 +41,13 @@ public class QsofaDetector
{ {
await cache.StringSetAsync( await cache.StringSetAsync(
key, value.ToString(), TimeSpan.FromSeconds(QsofaTtlSeconds)); key, value.ToString(), TimeSpan.FromSeconds(QsofaTtlSeconds));
_logger.LogDebug("qSOFA criterion set: {Key}={Value} (TTL={Ttl}s)",
key, value, QsofaTtlSeconds);
} }
else else
{ {
await cache.KeyDeleteAsync(key); await cache.KeyDeleteAsync(key);
_logger.LogDebug("qSOFA criterion cleared: {Key}", key);
} }
var allKeys = QsofaCalculator.AllCriterionKeys(encounterId); return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct);
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;
} }
public async Task<QsofaResult> SyncAlteredMentationAsync( public async Task<QsofaResult> SyncAlteredMentationAsync(
@@ -85,16 +67,18 @@ public class QsofaDetector
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value; var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
if (QsofaCalculator.MeetsGcsAlteredMentation(total)) if (QsofaCalculator.MeetsGcsAlteredMentation(total))
{ await cache.StringSetAsync(avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds));
await cache.StringSetAsync(
avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds));
}
else else
{
await cache.KeyDeleteAsync(avpuKey); await cache.KeyDeleteAsync(avpuKey);
}
} }
return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct);
}
private async Task<QsofaResult> EvaluateAndMaybeAlertAsync(
Guid encounterId, Guid patientId, CancellationToken ct)
{
var cache = _redis.GetDatabase();
var allKeys = QsofaCalculator.AllCriterionKeys(encounterId); var allKeys = QsofaCalculator.AllCriterionKeys(encounterId);
var values = await cache.StringGetAsync(allKeys); var values = await cache.StringGetAsync(allKeys);
var activeCount = QsofaCalculator.CountActiveCriteria(values); var activeCount = QsofaCalculator.CountActiveCriteria(values);
@@ -102,17 +86,19 @@ public class QsofaDetector
if (activeCount < 2) if (activeCount < 2)
return QsofaResult.InsufficientCriteria(activeCount); 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; return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
} }
private async Task<bool> TryCreateAlertAsync( private async Task<bool> TryCreateScreenAlertAsync(
Guid encounterId, Guid encounterId,
Guid patientId, Guid patientId,
int activeCount, int activeCount,
RedisValue[] criterionValues, RedisValue[] criterionValues,
CancellationToken ct) CancellationToken ct)
{ {
AlertCreationGuard.EnsureAllowed(AlertType.QsofaScreen);
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -120,17 +106,21 @@ public class QsofaDetector
var alertId = Guid.NewGuid(); var alertId = Guid.NewGuid();
var triggeredAt = DateTimeOffset.UtcNow; 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($""" var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at) (id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId}, SELECT {alertId}, {encounterId}, {patientId},
'QSOFA_WARNING', 'CRITICAL', {details}, 'OPEN', {triggeredAt} 'QSOFA_SCREEN', 'WARNING', {details}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId} WHERE encounter_id = {encounterId}
AND alert_type = 'QSOFA_WARNING' AND alert_type = 'QSOFA_SCREEN'
AND status IN ('OPEN', 'ESCALATED') AND status IN ('OPEN', 'ESCALATED')
) )
""", ct); """, ct);
@@ -138,8 +128,6 @@ public class QsofaDetector
if (affected == 0) if (affected == 0)
{ {
await tx.RollbackAsync(ct); await tx.RollbackAsync(ct);
_logger.LogDebug(
"QSOFA_WARNING already open for encounter {Id} — no new alert", encounterId);
return false; return false;
} }
@@ -152,8 +140,8 @@ public class QsofaDetector
alertId, alertId,
encounterId, encounterId,
patientId, patientId,
alertType = AlertType.QsofaWarning.ToDbString(), alertType = AlertType.QsofaScreen.ToDbString(),
severity = "Critical", severity = AlertSeverity.Warning.ToDbString(),
details, details,
triggeredAt, triggeredAt,
partitionKey = encounterId.ToString() partitionKey = encounterId.ToString()
@@ -167,24 +155,22 @@ public class QsofaDetector
_metrics.QsofaDetectionsTotal.Inc(); _metrics.QsofaDetectionsTotal.Inc();
_metrics.ClinicalAlertsTotal _metrics.ClinicalAlertsTotal
.WithLabels(AlertType.QsofaWarning.ToDbString(), AlertSeverity.Critical.ToDbString()) .WithLabels(AlertType.QsofaScreen.ToDbString(), AlertSeverity.Warning.ToDbString())
.Inc(); .Inc();
using (LogContext.PushProperty("EncounterId", encounterId)) using (LogContext.PushProperty("EncounterId", encounterId))
using (LogContext.PushProperty("PatientId", patientId)) using (LogContext.PushProperty("PatientId", patientId))
{ {
_logger.LogWarning( _logger.LogWarning(
"QSOFA_WARNING created. ActiveCriteriaCount={Count} AlertId={AlertId}", "QSOFA_SCREEN created. ActiveCriteriaCount={Count} AlertId={AlertId}",
activeCount, alertId); activeCount, alertId);
} }
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>(); // Screening alert — does NOT trigger sepsis bundle (Phase 27 Step 4)
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.QsofaWarning, ct);
return true; return true;
} }
private static string BuildDetails(int activeCount, RedisValue[] values) private static string BuildActiveCriteriaList(RedisValue[] values)
{ {
var activeParts = new List<string>(); var activeParts = new List<string>();
for (var i = 0; i < QsofaCalculator.QsofaCodes.Count; i++) for (var i = 0; i < QsofaCalculator.QsofaCodes.Count; i++)
@@ -192,7 +178,6 @@ public class QsofaDetector
if (values[i].HasValue) if (values[i].HasValue)
activeParts.Add($"{QsofaCalculator.QsofaCodes[i]}={values[i]}"); activeParts.Add($"{QsofaCalculator.QsofaCodes[i]}={values[i]}");
} }
return string.Join(", ", activeParts);
return $"qSOFA score {activeCount}/3: {string.Join(", ", activeParts)}";
} }
} }
@@ -12,6 +12,14 @@ public class SepsisAlertHandler
public async Task OnSepsisAlertCreatedAsync( public async Task OnSepsisAlertCreatedAsync(
Guid encounterId, Guid alertId, AlertType alertType, CancellationToken ct) 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); var bundle = await _bundleService.TryCreateBundleAsync(encounterId, alertId, alertType, ct);
if (bundle is not null) if (bundle is not null)
-177
View File
@@ -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<SirsDetector> _logger;
private readonly ClinicalMetrics _metrics;
public SirsDetector(
IConnectionMultiplexer redis,
IServiceProvider services,
ILogger<SirsDetector> logger,
ClinicalMetrics metrics)
{
_redis = redis;
_services = services;
_logger = logger;
_metrics = metrics;
}
public async Task<SirsResult> 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<bool> TryCreateAlertAsync(
Guid encounterId,
Guid patientId,
int activeCount,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<SepsisAlertHandler>();
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.SepsisWarning, ct);
return true;
}
}
@@ -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<string> SirsCodes =
new HashSet<string> { "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();
}
@@ -17,6 +17,10 @@ public class SepsisBundleService : ISepsisBundleService
public async Task<SepsisBundle?> TryCreateBundleAsync( public async Task<SepsisBundle?> TryCreateBundleAsync(
Guid encounterId, Guid triggeringAlertId, AlertType alertType, CancellationToken ct = default) 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 var exists = await _db.SepsisBundles
.AnyAsync(b => b.EncounterId == encounterId .AnyAsync(b => b.EncounterId == encounterId
&& b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress, ct); && b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress, ct);
@@ -276,6 +276,8 @@ public class SofaDetector
return false; return false;
} }
AlertCreationGuard.EnsureAllowed(alertType);
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -332,6 +334,12 @@ public class SofaDetector
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); await tx.CommitAsync(ct);
if (alertType == AlertType.SofaSepsis)
{
var handler = _services.GetRequiredService<SepsisAlertHandler>();
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, alertType, ct);
}
_metrics.ClinicalAlertsTotal _metrics.ClinicalAlertsTotal
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc(); .WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
+18 -2
View File
@@ -1,4 +1,3 @@
```markdown
# Sepsis Engine Design Decisions # Sepsis Engine Design Decisions
## Why Redis for SIRS state, not a PostgreSQL time-range query ## 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), 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 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. and defensible at interview with a clear scale inflection point named.
```
## 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)
+28
View File
@@ -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."
@@ -26,7 +26,7 @@ describe('AlertCard', () => {
it('showsAlertTypeAndSeverity', () => { it('showsAlertTypeAndSeverity', () => {
const wrapper = mount(AlertCard, { props: { alert: openAlert } }) const wrapper = mount(AlertCard, { props: { alert: openAlert } })
expect(wrapper.text()).toContain('Critical') expect(wrapper.text()).toContain('Critical')
expect(wrapper.text()).toContain('SEPSIS_WARNING') expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)')
}) })
it('acknowledgeButtonEmitsEvent', async () => { it('acknowledgeButtonEmitsEvent', async () => {
@@ -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)')
})
})
@@ -11,11 +11,11 @@ const sepsisAlert = {
triggeredAt: '2026-06-19T12:00:00Z', triggeredAt: '2026-06-19T12:00:00Z',
} }
const qsofaAlert = { const qsofaScreenAlert = {
id: 'alert-2', id: 'alert-2',
alertType: 'QsofaWarning', alertType: 'QsofaScreen',
severity: 'Warning', severity: 'Warning',
details: 'qSOFA score elevated', details: 'qSOFA screen positive',
triggeredAt: '2026-06-19T12:30:00Z', triggeredAt: '2026-06-19T12:30:00Z',
} }
@@ -24,16 +24,18 @@ describe('AlertReasoning', () => {
setActivePinia(createPinia()) setActivePinia(createPinia())
}) })
it('showsExplanationForSepsisWarning', () => { it('showsExplanationForLegacySepsisWarning', () => {
const wrapper = mount(AlertReasoning, { props: { alert: sepsisAlert } }) const wrapper = mount(AlertReasoning, { props: { alert: sepsisAlert } })
expect(wrapper.text()).toContain('SIRS / Sepsis Alert') expect(wrapper.text()).toContain('SIRS / Sepsis Alert (Legacy)')
expect(wrapper.text()).toContain('≥2 of 4 SIRS criteria met') expect(wrapper.text()).toContain('Historical alert')
expect(wrapper.text()).toContain('SOFA delta')
}) })
it('showsExplanationForQsofa', () => { it('showsExplanationForQsofaScreen', () => {
const wrapper = mount(AlertReasoning, { props: { alert: qsofaAlert } }) const wrapper = mount(AlertReasoning, { props: { alert: qsofaScreenAlert } })
expect(wrapper.text()).toContain('qSOFA Alert') expect(wrapper.text()).toContain('qSOFA Screen')
expect(wrapper.text()).toContain('≥2 of 3 qSOFA criteria met') expect(wrapper.text()).toContain('Bedside screen positive')
expect(wrapper.text()).toContain('Recommend ordering SOFA labs (PaO₂')
}) })
it('showsRawDetailsForUnknownType', () => { it('showsRawDetailsForUnknownType', () => {
@@ -28,8 +28,8 @@ describe('FeedbackSummary', () => {
it('showsPerAlertTypeBreakdown', () => { it('showsPerAlertTypeBreakdown', () => {
const wrapper = mount(FeedbackSummary) const wrapper = mount(FeedbackSummary)
expect(wrapper.text()).toContain('SEPSIS_WARNING') expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)')
expect(wrapper.text()).toContain('WARNING_HEART_RATE') expect(wrapper.text()).toContain('Warning Heart Rate')
expect(wrapper.text()).toContain('2 ratings') expect(wrapper.text()).toContain('2 ratings')
expect(wrapper.text()).toContain('1 ratings') expect(wrapper.text()).toContain('1 ratings')
}) })
@@ -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')
})
})
@@ -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')
})
})
@@ -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')
})
})
+37
View File
@@ -8,6 +8,14 @@ export function fetchCurrentQsofa(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/qsofa/current`) 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) { export function fetchSepsisBundle(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/sepsis-bundle/current`) 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`) 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 } = {}) { export async function fetchNews2History(encounterId, { limit = 100 } = {}) {
const all = [] const all = []
let cursor = null let cursor = null
+58 -2
View File
@@ -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 (912)',
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) { export function alertTypeLabel(type) {
if (!type) return '' 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) { export function alertStatusToApiFilter(status) {
@@ -23,6 +76,9 @@ const OBSERVATION_LABELS = {
AVPU: 'AVPU', AVPU: 'AVPU',
SUPPLEMENTAL_O2: 'Supplemental O₂', SUPPLEMENTAL_O2: 'Supplemental O₂',
WBC_K_UL: 'WBC', WBC_K_UL: 'WBC',
GCS_EYE: 'GCS Eye',
GCS_VERBAL: 'GCS Verbal',
GCS_MOTOR: 'GCS Motor',
} }
export function observationCodeLabel(code) { export function observationCodeLabel(code) {
@@ -1,16 +1,27 @@
<script setup> <script setup>
import { computed } from 'vue'
import Card from '@/components/ui/Card.vue' import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue' import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
import { alertTypeLabel } from '@/api/normalize' import { alertTypeLabel } from '@/api/normalize'
defineProps({ const props = defineProps({
alert: { type: Object, required: true }, alert: { type: Object, required: true },
}) })
const emit = defineEmits(['acknowledge', 'resolve']) const emit = defineEmits(['acknowledge', 'resolve'])
const actionHint = computed(() => {
const hints = {
QsofaScreen: 'Recommend SOFA labs',
SofaSepsis: 'Review organ breakdown · Initiate bundle',
GcsCritical: 'Urgent neuro assessment',
GcsWarning: 'Monitor consciousness',
}
return hints[props.alert.alertType] ?? null
})
function severityVariant(severity) { function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning' return severity === 'Critical' ? 'critical' : 'warning'
} }
@@ -35,11 +46,12 @@ function formatTime(iso) {
<template> <template>
<Card> <Card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Badge :variant="severityVariant(alert.severity)">{{ alert.severity }}</Badge> <Badge :variant="severityVariant(alert.severity)">{{ alert.severity }}</Badge>
<Badge variant="info" size="xs">{{ alert.status }}</Badge> <Badge variant="info" size="xs">{{ alert.status }}</Badge>
<Badge v-if="actionHint" variant="info" size="xs">{{ actionHint }}</Badge>
</div> </div>
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white"> <h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{{ alertTypeLabel(alert.alertType) }} {{ alertTypeLabel(alert.alertType) }}
@@ -12,14 +12,60 @@ const props = defineProps({
const CORRELATION_WINDOW_MS = 90 * 60 * 1000 const CORRELATION_WINDOW_MS = 90 * 60 * 1000
const reasoningMap = { const reasoningMap = {
WarningHeartRate: { label: 'Heart Rate Warning', explain: (a) => `Heart rate ${extractValue(a)} is in the warning range (91110 bpm or 4150 bpm).` }, WarningHeartRate: {
WarningSystolicBp: { label: 'Systolic BP Warning', explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101110 mmHg).` }, label: 'Heart Rate Warning',
WarningTempC: { label: 'Temperature Warning', explain: (a) => `Temperature ${extractValue(a)} is in the warning range.` }, explain: (a) => `Heart rate ${extractValue(a)} is in the warning range (91110 bpm or 4150 bpm).`,
SepsisWarning: { label: 'SIRS / Sepsis Alert', explain: () => '≥2 of 4 SIRS criteria met: temperature, heart rate, respiratory rate, WBC.' }, },
QsofaWarning: { label: 'qSOFA Alert', explain: () => '≥2 of 3 qSOFA criteria met: RR ≥22, SBP ≤100, altered mentation (AVPU ≥1).' }, WarningSystolicBp: {
News2Warning: { label: 'NEWS2 Medium Risk', explain: () => 'NEWS2 composite score 56 indicating medium clinical risk.' }, label: 'Systolic BP Warning',
News2Emergency: { label: 'NEWS2 High Risk', explain: () => 'NEWS2 composite score ≥7 or any single parameter scored 3.' }, explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101110 mmHg).`,
RapidDeterioration: { label: 'Rapid Deterioration', explain: () => 'Vital sign trajectory shows rapid change within the sliding window.' }, },
WarningTempC: {
label: 'Temperature Warning',
explain: (a) => `Temperature ${extractValue(a)} is in the warning range.`,
},
SepsisWarning: {
label: 'SIRS / Sepsis Alert (Legacy)',
explain: () =>
'Historical alert: ≥2 of 4 SIRS criteria met. New sepsis detection uses SOFA delta ≥ 2.',
},
QsofaWarning: {
label: 'qSOFA Alert (Legacy)',
explain: () => 'Historical alert: ≥2 of 3 qSOFA criteria met.',
},
QsofaScreen: {
label: 'qSOFA Screen',
explain: () =>
'Bedside screen positive (≥2/3). Recommend ordering SOFA labs to evaluate organ dysfunction.',
},
SofaSepsis: {
label: 'Sepsis Alert (SOFA)',
explain: (a) => explainSofaAlert(a, true),
},
SofaWarning: {
label: 'SOFA Warning',
explain: (a) => explainSofaAlert(a, false),
},
GcsCritical: {
label: 'GCS Critical',
explain: (a) => explainGcsAlert(a),
},
GcsWarning: {
label: 'GCS Warning',
explain: (a) => explainGcsAlert(a),
},
News2Warning: {
label: 'NEWS2 Medium Risk',
explain: () => 'NEWS2 composite score 56 indicating medium clinical risk.',
},
News2Emergency: {
label: 'NEWS2 High Risk',
explain: () => 'NEWS2 composite score ≥7 or any single parameter scored 3.',
},
RapidDeterioration: {
label: 'Rapid Deterioration',
explain: () => 'Vital sign trajectory shows rapid change within the sliding window.',
},
} }
const recentMedications = computed(() => { const recentMedications = computed(() => {
@@ -32,11 +78,45 @@ const recentMedications = computed(() => {
}) })
}) })
const actionHint = computed(() => {
const hints = {
QsofaScreen: 'Recommend ordering SOFA labs (PaO₂, platelets, bilirubin, creatinine).',
SofaSepsis: 'Review organ dysfunction and confirm sepsis bundle initiation.',
GcsCritical: 'Urgent neurological assessment — GCS ≤ 8.',
GcsWarning: 'Monitor consciousness closely — GCS 912.',
}
return hints[props.alert.alertType] ?? null
})
function extractValue(alert) { function extractValue(alert) {
const match = alert.details?.match(/(\d+\.?\d*)/) const match = alert.details?.match(/(\d+\.?\d*)/)
return match ? match[1] : '—' return match ? match[1] : '—'
} }
function explainGcsAlert(alert) {
const e = alert.details?.match(/E[=:]?\s*(\d)/i)?.[1]
const v = alert.details?.match(/V[=:]?\s*(\d)/i)?.[1]
const m = alert.details?.match(/M[=:]?\s*(\d)/i)?.[1]
if (e && v && m) return `GCS components: Eye ${e}, Verbal ${v}, Motor ${m}.`
return alert.details ?? 'GCS threshold crossed.'
}
function explainSofaAlert(alert, isSepsis) {
const delta = alert.details?.match(/delta\s*[+:]?\s*(\d+)/i)?.[1]
const baseline = alert.details?.match(/baseline\s*(\d+)/i)?.[1]
const current = alert.details?.match(/current\s*(\d+)/i)?.[1]
const parts = []
if (baseline && current && delta) {
parts.push(`SOFA score increased from ${baseline} to ${current} (delta +${delta}).`)
} else if (delta) {
parts.push(`SOFA delta +${delta} from baseline.`)
}
const organs = alert.details?.match(/organs?:\s*([^.]+)/i)?.[1]
if (organs) parts.push(`Organ dysfunction: ${organs.trim()}.`)
if (isSepsis) parts.push('Meets sepsis criteria: suspected infection + SOFA delta ≥ 2.')
return parts.length ? parts.join(' ') : (alert.details ?? 'SOFA threshold crossed.')
}
function formatMed(med) { function formatMed(med) {
return `${med.drugName} ${med.dose}${med.doseUnit} (${med.route})` return `${med.drugName} ${med.dose}${med.doseUnit} (${med.route})`
} }
@@ -58,6 +138,13 @@ function formatMed(med) {
{{ reasoningMap[alert.alertType]?.explain(alert) ?? alert.details }} {{ reasoningMap[alert.alertType]?.explain(alert) ?? alert.details }}
</p> </p>
<p
v-if="actionHint"
class="rounded bg-blue-50 px-4 py-2 text-sm text-blue-800 dark:bg-blue-950/40 dark:text-blue-200"
>
{{ actionHint }}
</p>
<div <div
v-if="recentMedications.length" v-if="recentMedications.length"
class="rounded bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200" class="rounded bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
@@ -70,7 +157,10 @@ function formatMed(med) {
</ul> </ul>
</div> </div>
<div v-if="alert.details" class="rounded bg-gray-50 p-4 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300"> <div
v-if="alert.details"
class="rounded bg-gray-50 p-4 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ alert.details }} {{ alert.details }}
</div> </div>
@@ -0,0 +1,107 @@
<script setup>
import { ref, computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
defineProps({
submitting: { type: Boolean, default: false },
})
const emit = defineEmits(['submit'])
const eye = ref(4)
const verbal = ref(5)
const motor = ref(6)
const total = computed(() => eye.value + verbal.value + motor.value)
const classification = computed(() => {
if (total.value <= 8) return { label: 'Severe', variant: 'critical' }
if (total.value <= 12) return { label: 'Moderate', variant: 'warning' }
return { label: 'Mild', variant: 'success' }
})
const eyeOptions = [
{ value: 1, label: '1 — No opening' },
{ value: 2, label: '2 — To pressure' },
{ value: 3, label: '3 — To voice' },
{ value: 4, label: '4 — Spontaneous' },
]
const verbalOptions = [
{ value: 1, label: '1 — None' },
{ value: 2, label: '2 — Incomprehensible' },
{ value: 3, label: '3 — Inappropriate' },
{ value: 4, label: '4 — Confused' },
{ value: 5, label: '5 — Oriented' },
]
const motorOptions = [
{ value: 1, label: '1 — None' },
{ value: 2, label: '2 — Extension' },
{ value: 3, label: '3 — Abnormal flexion' },
{ value: 4, label: '4 — Withdrawal' },
{ value: 5, label: '5 — Localizing' },
{ value: 6, label: '6 — Obeys commands' },
]
function submit() {
emit('submit', { eye: eye.value, verbal: verbal.value, motor: motor.value })
}
</script>
<template>
<div class="space-y-4">
<h3 class="text-sm font-semibold dark:text-white">Record Glasgow Coma Scale</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="min-w-0">
<label class="mb-2 block text-xs text-gray-500 dark:text-gray-400">Eye (E)</label>
<select
v-model.number="eye"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option v-for="opt in eyeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="min-w-0">
<label class="mb-2 block text-xs text-gray-500 dark:text-gray-400">Verbal (V)</label>
<select
v-model.number="verbal"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option v-for="opt in verbalOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="min-w-0">
<label class="mb-2 block text-xs text-gray-500 dark:text-gray-400">Motor (M)</label>
<select
v-model.number="motor"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option v-for="opt in motorOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="text-lg font-bold dark:text-white">GCS {{ total }}/15</span>
<Badge :variant="classification.variant" size="xs">{{ classification.label }}</Badge>
<span class="text-xs text-gray-500 dark:text-gray-400">E{{ eye }} V{{ verbal }} M{{ motor }}</span>
</div>
<button
type="button"
class="w-full rounded bg-blue-500 px-6 py-2 text-sm font-medium text-white hover:bg-blue-600 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:opacity-50 sm:w-auto"
:disabled="submitting"
@click="submit"
>
{{ submitting ? 'Saving…' : 'Record GCS' }}
</button>
</div>
</div>
</template>
@@ -1,40 +1,38 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import Card from '@/components/ui/Card.vue' import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import * as clinicalApi from '@/api/clinical' import GcsEntryForm from '@/components/patient/GcsEntryForm.vue'
import { useScoringStore } from '@/stores/scoring'
const props = defineProps({ const scoring = useScoringStore()
news2: { type: Object, default: null }, const {
encounter: { type: Object, required: true }, news2,
}) qsofa,
gcsTotal,
gcsComponents,
gcsClassificationDisplay,
sofaTotal,
sofaDelta,
sofaOrgans,
news2Variant,
qsofaVariant,
submittingGcs,
} = storeToRefs(scoring)
const qsofa = ref(null) const showGcsForm = ref(false)
async function loadQsofa() { function sofaOrganVariant(score) {
if (!props.encounter?.id) return if (score === 0) return 'success'
try { if (score <= 2) return 'warning'
qsofa.value = await clinicalApi.fetchCurrentQsofa(props.encounter.id) return 'critical'
} catch {
qsofa.value = null
}
} }
watch(() => props.encounter?.id, loadQsofa, { immediate: true }) async function onGcsSubmit({ eye, verbal, motor }) {
await scoring.submitGcs(eye, verbal, motor)
const news2Variant = computed(() => { showGcsForm.value = false
const score = props.news2?.totalScore ?? 0 }
if (score >= 7 || props.news2?.hasSingleParamThree) return 'critical'
if (score >= 5) return 'warning'
return 'success'
})
const qsofaVariant = computed(() => {
const count = qsofa.value?.activeCriteria ?? 0
if (count >= 2) return 'critical'
if (count === 1) return 'warning'
return 'success'
})
</script> </script>
<template> <template>
@@ -45,23 +43,102 @@ const qsofaVariant = computed(() => {
</h2> </h2>
</template> </template>
<div class="grid grid-cols-2 gap-4"> <div class="space-y-6">
<div> <!-- NEWS2 -->
<div class="text-xs text-gray-500 dark:text-gray-400">NEWS2</div> <section class="space-y-2">
<div class="mt-2 flex items-center gap-2"> <div class="text-xs font-medium text-gray-500 dark:text-gray-400">NEWS2</div>
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="news2Variant">{{ news2?.totalScore ?? '—' }}</Badge> <Badge :variant="news2Variant">{{ news2?.totalScore ?? '—' }}</Badge>
<span v-if="news2?.riskLevel" class="text-xs text-gray-500 dark:text-gray-400"> <span v-if="news2?.riskLevel" class="text-xs text-gray-500 dark:text-gray-400">
{{ news2.riskLevel }} {{ news2.riskLevel }}
</span> </span>
</div> </div>
</div> </section>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">qSOFA</div> <!-- GCS -->
<div class="mt-2"> <section class="space-y-2 border-t border-gray-100 pt-4 dark:border-gray-700">
<Badge :variant="qsofaVariant">{{ qsofa?.activeCriteria ?? '—' }}</Badge> <div class="flex flex-wrap items-center justify-between gap-2">
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">/ 3 criteria</span> <div class="text-xs font-medium text-gray-500 dark:text-gray-400">GCS</div>
<button
type="button"
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
@click="showGcsForm = !showGcsForm"
>
{{ showGcsForm ? 'Cancel' : 'Record GCS' }}
</button>
</div> </div>
</div> <div v-if="gcsTotal != null" class="flex flex-wrap items-center gap-2">
<Badge :variant="gcsClassificationDisplay?.variant ?? 'info'">
{{ gcsTotal }}/15
</Badge>
<span v-if="gcsClassificationDisplay" class="text-xs text-gray-500 dark:text-gray-400">
{{ gcsClassificationDisplay.label }}
</span>
<span v-if="gcsComponents" class="text-xs text-gray-500 dark:text-gray-400">
E{{ gcsComponents.eye }} V{{ gcsComponents.verbal }} M{{ gcsComponents.motor }}
</span>
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">No GCS recorded</div>
<Transition name="fade">
<GcsEntryForm
v-if="showGcsForm"
:submitting="submittingGcs"
@submit="onGcsSubmit"
/>
</Transition>
</section>
<!-- SOFA (compact) -->
<section class="space-y-2 border-t border-gray-100 pt-4 dark:border-gray-700">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">SOFA</div>
<div v-if="sofaTotal != null" class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="sofaDelta != null && sofaDelta >= 2 ? 'critical' : 'info'">
{{ sofaTotal }}/24
</Badge>
<Badge v-if="sofaDelta != null && sofaDelta >= 2" variant="critical" size="xs">
Δ +{{ sofaDelta }}
</Badge>
<Badge v-else-if="sofaDelta === 1" variant="warning" size="xs">Δ +1</Badge>
</div>
<div class="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Badge
v-for="organ in sofaOrgans"
:key="organ.name"
:variant="sofaOrganVariant(organ.score)"
size="xs"
>
{{ organ.name }} {{ organ.score }}
</Badge>
</div>
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">Labs pending</div>
</section>
<!-- qSOFA Screen -->
<section class="space-y-2 border-t border-gray-100 pt-4 dark:border-gray-700">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">qSOFA Screen</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Bedside screening qSOFA 2 suggests ordering SOFA labs
</p>
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="qsofaVariant">
{{ qsofa?.activeCriteria ?? '—' }}
</Badge>
<span class="text-xs text-gray-500 dark:text-gray-400">/ 3 criteria</span>
</div>
</section>
</div> </div>
</Card> </Card>
</template> </template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -2,10 +2,12 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import Card from '@/components/ui/Card.vue' import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import { bundleElementLabel } from '@/api/normalize' import { bundleElementLabel, bundleTriggerLabel } from '@/api/normalize'
const props = defineProps({ 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()) const now = ref(Date.now())
@@ -21,7 +23,17 @@ onBeforeUnmount(() => {
if (timer) clearInterval(timer) 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(() => { const remainingMs = computed(() => {
if (!props.bundle?.deadlineAt) return 0
const deadline = new Date(props.bundle.deadlineAt).getTime() const deadline = new Date(props.bundle.deadlineAt).getTime()
return Math.max(0, deadline - now.value) return Math.max(0, deadline - now.value)
}) })
@@ -34,7 +46,7 @@ const countdown = computed(() => {
}) })
const complianceVariant = computed(() => { const complianceVariant = computed(() => {
const status = props.bundle.complianceStatus const status = props.bundle?.complianceStatus
if (status === 'Compliant') return 'success' if (status === 'Compliant') return 'success'
if (status === 'NonCompliant') return 'critical' if (status === 'NonCompliant') return 'critical'
return 'warning' return 'warning'
@@ -52,43 +64,72 @@ function elementComplete(element) {
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Sepsis Bundle Sepsis Bundle
</h2> </h2>
<Badge :variant="complianceVariant">{{ bundle.complianceStatus }}</Badge> <Badge v-if="bundle" :variant="complianceVariant">{{ bundle.complianceStatus }}</Badge>
</div> </div>
</template> </template>
<div class="mb-4 flex items-center justify-between rounded-lg bg-gray-50 p-4 dark:bg-gray-800"> <div
<span class="text-sm text-gray-600 dark:text-gray-300">Time to deadline</span> v-if="showQsofaScreenMessage"
<span class="mb-4 rounded-lg bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
class="font-mono text-lg font-semibold" >
:class="remainingMs === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'" qSOFA screen positive order SOFA labs to evaluate organ dysfunction.
>
{{ countdown }}
</span>
</div> </div>
<ul class="space-y-2"> <div v-if="sofa?.totalScore != null" class="mb-4 flex flex-wrap items-center gap-2 text-sm dark:text-gray-300">
<li <span>Current SOFA:</span>
v-for="element in bundle.elements" <Badge variant="info" size="xs">{{ sofa.totalScore }}/24</Badge>
:key="element.id" <Badge
class="flex items-center gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700" v-if="sofa.deltaFromBaseline != null && sofa.deltaFromBaseline >= 2"
variant="critical"
size="xs"
> >
Δ +{{ sofa.deltaFromBaseline }}
</Badge>
</div>
<template v-if="bundle">
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
Triggered by {{ triggerLabel }}
</p>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<span class="text-sm text-gray-600 dark:text-gray-300">Time to deadline</span>
<span <span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs" class="font-mono text-lg font-semibold"
:class="elementComplete(element) :class="remainingMs === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'"
> >
{{ elementComplete(element) ? '✓' : '○' }} {{ countdown }}
</span> </span>
<span </div>
class="text-sm"
:class="elementComplete(element) <ul class="space-y-2">
? 'text-gray-500 line-through dark:text-gray-400' <li
: 'text-gray-900 dark:text-white'" v-for="element in bundle.elements"
:key="element.id"
class="flex flex-wrap items-center gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
> >
{{ bundleElementLabel(element.elementType) }} <span
</span> class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs"
</li> :class="elementComplete(element)
</ul> ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'"
>
{{ elementComplete(element) ? '✓' : '○' }}
</span>
<span
class="min-w-0 flex-1 text-sm"
:class="elementComplete(element)
? 'text-gray-500 line-through dark:text-gray-400'
: 'text-gray-900 dark:text-white'"
>
{{ bundleElementLabel(element.elementType) }}
</span>
</li>
</ul>
</template>
<p v-else class="text-sm text-gray-500 dark:text-gray-400">
No active sepsis bundle for this encounter.
</p>
</Card> </Card>
</template> </template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue'
import { useSofa } from '@/composables/useSofa'
import Badge from '@/components/ui/Badge.vue'
import Card from '@/components/ui/Card.vue'
const props = defineProps({
encounterId: { type: String, required: true },
})
const encounterIdRef = computed(() => props.encounterId)
const { total, delta, organs, hasStaleData, staleness, loading, fetch } = useSofa(encounterIdRef)
function organVariant(score) {
if (score === 0) return 'success'
if (score <= 2) return 'warning'
return 'critical'
}
</script>
<template>
<Card>
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<h3 class="text-sm font-semibold dark:text-white">SOFA Score</h3>
<div v-if="loading" class="text-sm text-gray-400 dark:text-gray-500">Loading</div>
<div v-else-if="total !== null" class="flex flex-wrap items-center gap-2">
<span class="text-2xl font-bold dark:text-white">{{ total }}/24</span>
<Badge v-if="delta !== null && delta >= 2" variant="critical" size="xs">
+{{ delta }} from baseline
</Badge>
<Badge v-else-if="delta !== null && delta === 1" variant="warning" size="xs">
+{{ delta }} from baseline
</Badge>
</div>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">Labs pending</span>
</div>
<div v-if="organs.length" class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div
v-for="organ in organs"
:key="organ.name"
class="rounded-lg bg-gray-50 p-4 text-center dark:bg-gray-800"
>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ organ.name }}</div>
<div class="mt-2">
<Badge :variant="organVariant(organ.score)" size="sm">
{{ organ.score }}/{{ organ.max }}
</Badge>
</div>
</div>
</div>
<div
v-if="hasStaleData"
class="rounded bg-amber-50 px-4 py-2 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
>
<span v-if="staleness?.missingComponents?.length">
Missing: {{ staleness.missingComponents.join(', ') }}.
</span>
<span v-if="staleness?.staleComponents?.length">
Stale: {{ staleness.staleComponents.join(', ') }}.
</span>
<span v-if="staleness?.usedSpO2Fallback">
Using SpO/FiO proxy (no arterial blood gas).
</span>
</div>
<button
type="button"
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
@click="fetch"
>
Refresh SOFA
</button>
</div>
</Card>
</template>
@@ -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,
}
}
@@ -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,
}
}
+130
View File
@@ -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,
}
})
@@ -5,10 +5,12 @@ import { storeToRefs } from 'pinia'
import { usePolling } from '@/composables/usePolling' import { usePolling } from '@/composables/usePolling'
import { useReplayControls } from '@/composables/useReplayControls' import { useReplayControls } from '@/composables/useReplayControls'
import { useAlertStore } from '@/stores/alerts' import { useAlertStore } from '@/stores/alerts'
import { useScoringStore } from '@/stores/scoring'
import * as encountersApi from '@/api/encounters' import * as encountersApi from '@/api/encounters'
import * as clinicalApi from '@/api/clinical' import * as clinicalApi from '@/api/clinical'
import VitalsPanel from '@/components/patient/VitalsPanel.vue' import VitalsPanel from '@/components/patient/VitalsPanel.vue'
import ScoresPanel from '@/components/patient/ScoresPanel.vue' import ScoresPanel from '@/components/patient/ScoresPanel.vue'
import SofaScorePanel from '@/components/patient/SofaScorePanel.vue'
import AlertsList from '@/components/patient/AlertsList.vue' import AlertsList from '@/components/patient/AlertsList.vue'
import OrdersPanel from '@/components/patient/OrdersPanel.vue' import OrdersPanel from '@/components/patient/OrdersPanel.vue'
import SepsisBundlePanel from '@/components/patient/SepsisBundlePanel.vue' import SepsisBundlePanel from '@/components/patient/SepsisBundlePanel.vue'
@@ -20,7 +22,9 @@ import Skeleton from '@/components/ui/Skeleton.vue'
const route = useRoute() const route = useRoute()
const alertStore = useAlertStore() const alertStore = useAlertStore()
const scoringStore = useScoringStore()
const { alerts } = storeToRefs(alertStore) const { alerts } = storeToRefs(alertStore)
const { qsofa, sofa } = storeToRefs(scoringStore)
const { const {
isPaused, isPaused,
speed, speed,
@@ -42,7 +46,6 @@ const {
const encounter = ref(null) const encounter = ref(null)
const loading = ref(true) const loading = ref(true)
const observations = ref([]) const observations = ref([])
const news2 = ref(null)
const news2History = ref([]) const news2History = ref([])
const medications = ref([]) const medications = ref([])
const sepsisBundle = ref(null) const sepsisBundle = ref(null)
@@ -85,10 +88,9 @@ async function loadAll() {
const id = route.params.encounterId const id = route.params.encounterId
loading.value = true loading.value = true
try { 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.fetchEncounter(id),
encountersApi.fetchObservations(id), encountersApi.fetchObservations(id),
clinicalApi.fetchCurrentNews2(id).catch(() => null),
clinicalApi.fetchNews2History(id).catch(() => []), clinicalApi.fetchNews2History(id).catch(() => []),
clinicalApi.fetchMedications(id).catch(() => []), clinicalApi.fetchMedications(id).catch(() => []),
clinicalApi.fetchSepsisBundle(id).catch(() => null), clinicalApi.fetchSepsisBundle(id).catch(() => null),
@@ -97,11 +99,11 @@ async function loadAll() {
await alertStore.loadAlerts(id) await alertStore.loadAlerts(id)
encounter.value = enc encounter.value = enc
observations.value = obs observations.value = obs
news2.value = n2
news2History.value = history news2History.value = history
medications.value = meds medications.value = meds
sepsisBundle.value = bundle sepsisBundle.value = bundle
orders.value = ord.items ?? ord orders.value = ord.items ?? ord
scoringStore.startPolling(id)
syncReplayBounds() syncReplayBounds()
} finally { } finally {
loading.value = false loading.value = false
@@ -134,6 +136,7 @@ watch(() => route.params.encounterId, () => {
selectedAlert.value = null selectedAlert.value = null
nextAlertIndex = 0 nextAlertIndex = 0
pause() pause()
scoringStore.stopPolling()
loadAll() loadAll()
}) })
@@ -146,7 +149,10 @@ watch(openAlerts, (list) => {
watch([observations, news2History, alerts], syncReplayBounds, { deep: true }) watch([observations, news2History, alerts], syncReplayBounds, { deep: true })
onBeforeUnmount(() => stopPlayback()) onBeforeUnmount(() => {
stopPlayback()
scoringStore.stopPolling()
})
</script> </script>
<template> <template>
@@ -162,7 +168,7 @@ onBeforeUnmount(() => stopPlayback())
</div> </div>
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<ScoresPanel :news2="news2" :encounter="encounter" /> <ScoresPanel />
<VitalsPanel :observations="replayObservations" /> <VitalsPanel :observations="replayObservations" />
<AlertsList <AlertsList
:encounter-id="route.params.encounterId" :encounter-id="route.params.encounterId"
@@ -178,10 +184,16 @@ onBeforeUnmount(() => stopPlayback())
/> />
<div class="grid min-w-0 gap-4 lg:grid-cols-2"> <div class="grid min-w-0 gap-4 lg:grid-cols-2">
<SofaScorePanel :encounter-id="route.params.encounterId" />
<OrdersPanel :orders="orders" /> <OrdersPanel :orders="orders" />
<SepsisBundlePanel v-if="sepsisBundle" :bundle="sepsisBundle" />
</div> </div>
<SepsisBundlePanel
:bundle="sepsisBundle"
:qsofa="qsofa"
:sofa="sofa"
/>
<div id="clinical-review" class="w-full min-w-0 space-y-8"> <div id="clinical-review" class="w-full min-w-0 space-y-8">
<TrendsGrid :observations="replayObservations" /> <TrendsGrid :observations="replayObservations" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" /> <News2History v-if="replayNews2History.length" :history="replayNews2History" />