feature: Sepsis Engine Refactor: Remove SIRS, Rewire qSOFA + Bundle
Frontend: GCS Entry, SOFA Display, Sepsis UI Refactor
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
+1085
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",
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Executable
+28
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,60 @@
|
|||||||
// Map API PascalCase alert types to display labels (e.g. WarningHeartRate → WARNING_HEART_RATE)
|
const ALERT_TYPE_LABELS = {
|
||||||
|
SepsisWarning: 'Sepsis Warning (SIRS — Legacy)',
|
||||||
|
QsofaWarning: 'qSOFA Alert (Legacy)',
|
||||||
|
QsofaScreen: 'qSOFA Screen',
|
||||||
|
SofaSepsis: 'Sepsis Alert (SOFA)',
|
||||||
|
SofaWarning: 'SOFA Warning',
|
||||||
|
GcsCritical: 'GCS Critical (≤ 8)',
|
||||||
|
GcsWarning: 'GCS Warning (9–12)',
|
||||||
|
News2Warning: 'NEWS2 Warning',
|
||||||
|
News2Emergency: 'NEWS2 Emergency',
|
||||||
|
RapidDeterioration: 'Rapid Deterioration',
|
||||||
|
CriticalHeartRate: 'Critical Heart Rate',
|
||||||
|
CriticalTempC: 'Critical Temperature',
|
||||||
|
CriticalPotassiumMeqL: 'Critical Potassium',
|
||||||
|
CriticalSpo2: 'Critical SpO₂',
|
||||||
|
CriticalRespRate: 'Critical Respiratory Rate',
|
||||||
|
CriticalWbcKUl: 'Critical WBC',
|
||||||
|
CriticalSystolicBp: 'Critical Systolic BP',
|
||||||
|
CriticalDiastolicBp: 'Critical Diastolic BP',
|
||||||
|
CriticalLactateMmolL: 'Critical Lactate',
|
||||||
|
CriticalAvpu: 'Critical AVPU',
|
||||||
|
CriticalGlucoseMgDl: 'Critical Glucose',
|
||||||
|
WarningHeartRate: 'Warning Heart Rate',
|
||||||
|
WarningTempC: 'Warning Temperature',
|
||||||
|
WarningPotassiumMeqL: 'Warning Potassium',
|
||||||
|
WarningSpo2: 'Warning SpO₂',
|
||||||
|
WarningRespRate: 'Warning Respiratory Rate',
|
||||||
|
WarningWbcKUl: 'Warning WBC',
|
||||||
|
WarningSystolicBp: 'Warning Systolic BP',
|
||||||
|
WarningDiastolicBp: 'Warning Diastolic BP',
|
||||||
|
WarningLactateMmolL: 'Warning Lactate',
|
||||||
|
WarningGlucoseMgDl: 'Warning Glucose',
|
||||||
|
CriticalPao2MmHg: 'Critical PaO₂',
|
||||||
|
WarningPao2MmHg: 'Warning PaO₂',
|
||||||
|
CriticalPlateletKUl: 'Critical Platelets',
|
||||||
|
WarningPlateletKUl: 'Warning Platelets',
|
||||||
|
CriticalBilirubinMgDl: 'Critical Bilirubin',
|
||||||
|
WarningBilirubinMgDl: 'Warning Bilirubin',
|
||||||
|
CriticalCreatinineMgDl: 'Critical Creatinine',
|
||||||
|
WarningCreatinineMgDl: 'Warning Creatinine',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUNDLE_TRIGGER_LABELS = {
|
||||||
|
SOFA_SEPSIS: 'SOFA delta ≥ 2',
|
||||||
|
SEPSIS_WARNING: 'SIRS criteria (Legacy)',
|
||||||
|
QSOFA_WARNING: 'qSOFA alert (Legacy)',
|
||||||
|
}
|
||||||
|
|
||||||
export function alertTypeLabel(type) {
|
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 (91–110 bpm or 41–50 bpm).` },
|
WarningHeartRate: {
|
||||||
WarningSystolicBp: { label: 'Systolic BP Warning', explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101–110 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 (91–110 bpm or 41–50 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 5–6 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 (101–110 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 5–6 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 9–12.',
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user