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

Frontend: GCS Entry, SOFA Display, Sepsis UI Refactor
This commit is contained in:
voltsrage
2026-06-21 03:56:27 +08:00
parent 93ea473d2b
commit bf46e6554a
48 changed files with 2686 additions and 714 deletions
@@ -34,13 +34,10 @@ public class SepsisEngineService : BackgroundService
};
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);
_logger.LogInformation("SepsisEngineService started — consumer group: sepsis-engine");
_logger.LogInformation(
"SepsisEngineService started — consumer group: sepsis-engine (qSOFA screening only)");
try
{
@@ -54,19 +51,9 @@ public class SepsisEngineService : BackgroundService
var evt = JsonSerializer.Deserialize<SepsisObservationEvent>(
result.Message.Value, EventJsonOptions)!;
// Create a scope per message — both detectors are scoped and
// each owns a fresh DbContext when creating alerts.
using var scope = _services.CreateScope();
var sirsDetector = scope.ServiceProvider.GetRequiredService<SirsDetector>();
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(
evt.EncounterId,
evt.PatientId,
@@ -74,19 +61,12 @@ public class SepsisEngineService : BackgroundService
evt.Value,
stoppingToken);
if (sirsOutcome.Outcome == SirsOutcome.AlertCreated)
_logger.LogWarning(
"SEPSIS_WARNING created via SepsisEngine " +
"— encounter={EncounterId} code={Code} value={Value}",
evt.EncounterId, evt.ObservationCode, evt.Value);
if (qsofaOutcome.Outcome == QsofaOutcome.AlertCreated)
_logger.LogWarning(
"QSOFA_WARNING created via SepsisEngine " +
_logger.LogInformation(
"QSOFA_SCREEN created via SepsisEngine " +
"— encounter={EncounterId} code={Code} value={Value}",
evt.EncounterId, evt.ObservationCode, evt.Value);
// Commit only after successful processing.
consumer.Commit(result);
}
catch (OperationCanceledException)
@@ -98,8 +78,6 @@ public class SepsisEngineService : BackgroundService
_logger.LogError(ex,
"SepsisEngine failed on topic={Topic} offset={Offset} — not committing",
result?.Topic, result?.Offset.Value);
// Back off before retrying so a persistent failure (e.g., Redis down)
// does not spin the loop at maximum throughput.
await Task.Delay(2000, stoppingToken);
}
}
@@ -18,13 +18,18 @@ public class ClinicalAlertConfiguration : IEntityTypeConfiguration<ClinicalAlert
"'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', " +
"'GCS_CRITICAL', 'GCS_WARNING')");
"'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN', " +
"'GCS_CRITICAL', 'GCS_WARNING', " +
"'SOFA_SEPSIS', 'SOFA_WARNING')");
});
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
@@ -1,5 +1,6 @@
public enum AlertType
{
[Obsolete("Legacy — replaced by SOFA_SEPSIS in Phase 27. Retained for historical alert queries.")]
SepsisWarning,
CriticalHeartRate,
CriticalTempC,
@@ -30,8 +31,11 @@ public enum AlertType
RapidDeterioration,
[Obsolete("Legacy — replaced by QSOFA_SCREEN in Phase 27. Retained for historical alert queries.")]
QsofaWarning,
QsofaScreen,
GcsCritical,
GcsWarning,
@@ -90,6 +94,7 @@ public static class AlertTypeExtensions
AlertType.WarningCreatinineMgDl => "WARNING_CREATININE_MG_DL",
AlertType.SofaSepsis => "SOFA_SEPSIS",
AlertType.SofaWarning => "SOFA_WARNING",
AlertType.QsofaScreen => "QSOFA_SCREEN",
_ => throw new ArgumentOutOfRangeException(nameof(t))
};
@@ -121,6 +126,7 @@ public static class AlertTypeExtensions
"NEWS2_EMERGENCY" => AlertType.News2Emergency,
"RAPID_DETERIORATION" => AlertType.RapidDeterioration,
"QSOFA_WARNING" => AlertType.QsofaWarning,
"QSOFA_SCREEN" => AlertType.QsofaScreen,
"GCS_CRITICAL" => AlertType.GcsCritical,
"GCS_WARNING" => AlertType.GcsWarning,
"CRITICAL_PAO2_MMHG" => AlertType.CriticalPao2MmHg,
@@ -189,7 +195,7 @@ public static class AlertTypeExtensions
AlertType.RapidDeterioration => false,
AlertType.GcsCritical => false, // trajectory alerts are never suppressed
AlertType.SofaSepsis => false,
_ => true // all Warning* types, News2Warning, and QsofaWarning
_ => true // all Warning* types, News2Warning, QsofaScreen, SofaWarning, GcsWarning
};
public static string? ObservationCodeForWarning(this AlertType t) => t switch
+2
View File
@@ -141,6 +141,8 @@ public class GcsDetector
}
}
AlertCreationGuard.EnsureAllowed(alertType);
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddQsofaScreenAlertTypecs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE clinical_alerts DROP CONSTRAINT chk_clinical_alerts_alert_type;
ALTER TABLE clinical_alerts ADD CONSTRAINT chk_clinical_alerts_alert_type
CHECK (alert_type IN (
'SEPSIS_WARNING',
'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L',
'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL',
'CRITICAL_SYSTOLIC_BP', 'CRITICAL_DIASTOLIC_BP', 'CRITICAL_LACTATE_MMOL_L',
'CRITICAL_AVPU', 'CRITICAL_GLUCOSE_MG_DL',
'CRITICAL_PAO2_MMHG', 'CRITICAL_PLATELET_K_UL',
'CRITICAL_BILIRUBIN_MG_DL', 'CRITICAL_CREATININE_MG_DL',
'WARNING_HEART_RATE', 'WARNING_TEMP_C', 'WARNING_POTASSIUM_MEQ_L',
'WARNING_SPO2', 'WARNING_RESP_RATE', 'WARNING_WBC_K_UL',
'WARNING_SYSTOLIC_BP', 'WARNING_DIASTOLIC_BP', 'WARNING_LACTATE_MMOL_L',
'WARNING_GLUCOSE_MG_DL',
'WARNING_PAO2_MMHG', 'WARNING_PLATELET_K_UL',
'WARNING_BILIRUBIN_MG_DL', 'WARNING_CREATININE_MG_DL',
'NEWS2_WARNING', 'NEWS2_EMERGENCY',
'RAPID_DETERIORATION', 'QSOFA_WARNING', 'QSOFA_SCREEN',
'GCS_CRITICAL', 'GCS_WARNING',
'SOFA_SEPSIS', 'SOFA_WARNING'
));
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -156,7 +156,7 @@ namespace VigilCareClinicalAPI.Migrations
b.ToTable("clinical_alerts", null, t =>
{
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')");
@@ -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();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -11,19 +11,12 @@ public sealed class ClinicalMetrics
"Total observations ingested, labeled by observation code and source.",
labelNames: new[] { "observation_code", "source" });
// Labeled by alert_type (THRESHOLD_BREACH, SEPSIS_WARNING, QSOFA_WARNING) and severity
// (Critical, Warning) so the dashboard can show Critical vs Warning rates separately.
// Labeled by alert_type and severity (e.g. QSOFA_SCREEN/WARNING, SOFA_SEPSIS/CRITICAL).
public readonly Counter ClinicalAlertsTotal = Metrics.CreateCounter(
"clinical_alerts_total",
"Total clinical alerts generated, labeled by type and severity.",
labelNames: new[] { "alert_type", "severity" });
// Incremented only when INSERT WHERE NOT EXISTS succeeds — duplicate-suppressed
// SIRS detections do not count. This is the true detection rate, not the evaluation rate.
public readonly Counter SirsDetectionsTotal = Metrics.CreateCounter(
"sirs_detections_total",
"Total SEPSIS_WARNING alerts generated by the sepsis detection engine.");
public readonly Counter News2ScoresTotal = Metrics.CreateCounter(
"news2_scores_total",
"Total NEWS2 scores computed, labeled by risk level.",
@@ -48,7 +41,7 @@ public sealed class ClinicalMetrics
public readonly Counter QsofaDetectionsTotal = Metrics.CreateCounter(
"qsofa_detections_total",
"Total QSOFA_WARNING alerts generated by the qSOFA scoring engine.");
"Total QSOFA_SCREEN alerts generated by the qSOFA screening engine.");
public readonly Counter SepsisBundleComplianceTotal = Metrics.CreateCounter(
"sepsis_bundle_compliance_total",
-1
View File
@@ -105,7 +105,6 @@ try
builder.Services.AddScoped<IQsofaService, QsofaService>();
builder.Services.AddScoped<ISepsisBundleService, SepsisBundleService>();
builder.Services.AddScoped<SepsisAlertHandler>();
builder.Services.AddScoped<SirsDetector>();
builder.Services.AddScoped<QsofaDetector>();
builder.Services.AddScoped<UnacknowledgedAlertsCheck>();
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;
// qSOFA criteria (Sepsis-3 bedside screen):
// - Respiratory rate ≥ 22 breaths/min
// - Systolic blood pressure ≤ 100 mmHg
// - Altered mentation: AVPU ≥ 1 or GCS total < 15 (via SyncAlteredMentationAsync)
public static class QsofaCalculator
{
public static readonly IReadOnlyList<string> QsofaCodes = new[]
+28 -43
View File
@@ -5,7 +5,6 @@ using StackExchange.Redis;
public class QsofaDetector
{
// 30 minutes in seconds — same sliding window as SIRS.
private const int QsofaTtlSeconds = 1800;
private readonly IConnectionMultiplexer _redis;
@@ -42,30 +41,13 @@ public class QsofaDetector
{
await cache.StringSetAsync(
key, value.ToString(), TimeSpan.FromSeconds(QsofaTtlSeconds));
_logger.LogDebug("qSOFA criterion set: {Key}={Value} (TTL={Ttl}s)",
key, value, QsofaTtlSeconds);
}
else
{
await cache.KeyDeleteAsync(key);
_logger.LogDebug("qSOFA criterion cleared: {Key}", key);
}
var allKeys = QsofaCalculator.AllCriterionKeys(encounterId);
var values = await cache.StringGetAsync(allKeys);
var activeCount = QsofaCalculator.CountActiveCriteria(values);
_logger.LogDebug(
"qSOFA state for encounter {Id}: {Active}/3 criteria active after {Code}={Value}",
encounterId, activeCount, observationCode, value);
if (activeCount < 2)
return QsofaResult.InsufficientCriteria(activeCount);
var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, values, ct);
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct);
}
public async Task<QsofaResult> SyncAlteredMentationAsync(
@@ -85,16 +67,18 @@ public class QsofaDetector
var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
if (QsofaCalculator.MeetsGcsAlteredMentation(total))
{
await cache.StringSetAsync(
avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds));
}
await cache.StringSetAsync(avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds));
else
{
await cache.KeyDeleteAsync(avpuKey);
}
}
return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct);
}
private async Task<QsofaResult> EvaluateAndMaybeAlertAsync(
Guid encounterId, Guid patientId, CancellationToken ct)
{
var cache = _redis.GetDatabase();
var allKeys = QsofaCalculator.AllCriterionKeys(encounterId);
var values = await cache.StringGetAsync(allKeys);
var activeCount = QsofaCalculator.CountActiveCriteria(values);
@@ -102,17 +86,19 @@ public class QsofaDetector
if (activeCount < 2)
return QsofaResult.InsufficientCriteria(activeCount);
var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, values, ct);
var created = await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct);
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
}
private async Task<bool> TryCreateAlertAsync(
private async Task<bool> TryCreateScreenAlertAsync(
Guid encounterId,
Guid patientId,
int activeCount,
RedisValue[] criterionValues,
CancellationToken ct)
{
AlertCreationGuard.EnsureAllowed(AlertType.QsofaScreen);
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -120,17 +106,21 @@ public class QsofaDetector
var alertId = Guid.NewGuid();
var triggeredAt = DateTimeOffset.UtcNow;
var details = BuildDetails(activeCount, criterionValues);
var activeCriteria = BuildActiveCriteriaList(criterionValues);
var details =
$"qSOFA score ≥ 2 (criteria: {activeCriteria}). " +
"Recommend: order SOFA labs (PaO2/FiO2, platelets, bilirubin, creatinine) " +
"to evaluate for organ dysfunction.";
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
'QSOFA_WARNING', 'CRITICAL', {details}, 'OPEN', {triggeredAt}
'QSOFA_SCREEN', 'WARNING', {details}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = 'QSOFA_WARNING'
AND alert_type = 'QSOFA_SCREEN'
AND status IN ('OPEN', 'ESCALATED')
)
""", ct);
@@ -138,8 +128,6 @@ public class QsofaDetector
if (affected == 0)
{
await tx.RollbackAsync(ct);
_logger.LogDebug(
"QSOFA_WARNING already open for encounter {Id} — no new alert", encounterId);
return false;
}
@@ -152,8 +140,8 @@ public class QsofaDetector
alertId,
encounterId,
patientId,
alertType = AlertType.QsofaWarning.ToDbString(),
severity = "Critical",
alertType = AlertType.QsofaScreen.ToDbString(),
severity = AlertSeverity.Warning.ToDbString(),
details,
triggeredAt,
partitionKey = encounterId.ToString()
@@ -167,24 +155,22 @@ public class QsofaDetector
_metrics.QsofaDetectionsTotal.Inc();
_metrics.ClinicalAlertsTotal
.WithLabels(AlertType.QsofaWarning.ToDbString(), AlertSeverity.Critical.ToDbString())
.WithLabels(AlertType.QsofaScreen.ToDbString(), AlertSeverity.Warning.ToDbString())
.Inc();
using (LogContext.PushProperty("EncounterId", encounterId))
using (LogContext.PushProperty("PatientId", patientId))
{
_logger.LogWarning(
"QSOFA_WARNING created. ActiveCriteriaCount={Count} AlertId={AlertId}",
"QSOFA_SCREEN created. ActiveCriteriaCount={Count} AlertId={AlertId}",
activeCount, alertId);
}
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.QsofaWarning, ct);
// Screening alert — does NOT trigger sepsis bundle (Phase 27 Step 4)
return true;
}
private static string BuildDetails(int activeCount, RedisValue[] values)
private static string BuildActiveCriteriaList(RedisValue[] values)
{
var activeParts = new List<string>();
for (var i = 0; i < QsofaCalculator.QsofaCodes.Count; i++)
@@ -192,7 +178,6 @@ public class QsofaDetector
if (values[i].HasValue)
activeParts.Add($"{QsofaCalculator.QsofaCodes[i]}={values[i]}");
}
return $"qSOFA score {activeCount}/3: {string.Join(", ", activeParts)}";
return string.Join(", ", activeParts);
}
}
}
@@ -12,6 +12,14 @@ public class SepsisAlertHandler
public async Task OnSepsisAlertCreatedAsync(
Guid encounterId, Guid alertId, AlertType alertType, CancellationToken ct)
{
if (alertType != AlertType.SofaSepsis)
{
_logger.LogDebug(
"Alert type {AlertType} does not trigger sepsis bundle — skipping",
alertType.ToDbString());
return;
}
var bundle = await _bundleService.TryCreateBundleAsync(encounterId, alertId, alertType, ct);
if (bundle is not null)
@@ -19,4 +27,4 @@ public class SepsisAlertHandler
"Sepsis bundle {BundleId} created for encounter {EncounterId} (trigger={AlertType})",
bundle.Id, encounterId, alertType.ToDbString());
}
}
}
-177
View File
@@ -1,177 +0,0 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Serilog.Context;
using StackExchange.Redis;
public class SirsDetector
{
// 30 minutes in seconds. This is a clinical parameter: SIRS criteria evaluated
// outside a 30-minute window are clinically stale. The TTL enforces the window
// automatically — no cleanup job required.
private const int SirsTtlSeconds = 1800;
private readonly IConnectionMultiplexer _redis;
private readonly IServiceProvider _services;
private readonly ILogger<SirsDetector> _logger;
private readonly ClinicalMetrics _metrics;
public SirsDetector(
IConnectionMultiplexer redis,
IServiceProvider services,
ILogger<SirsDetector> logger,
ClinicalMetrics metrics)
{
_redis = redis;
_services = services;
_logger = logger;
_metrics = metrics;
}
public async Task<SirsResult> ProcessObservationAsync(
Guid encounterId,
Guid patientId,
string observationCode,
decimal value,
CancellationToken ct = default)
{
// Fast exit for non-SIRS codes. The sepsis engine subscribes to the full
// observation.recorded stream — the majority of messages (SpO2, potassium, glucose)
// are not SIRS-relevant and are discarded here without touching Redis or PostgreSQL.
if (!SirsEvaluator.SirsCodes.Contains(observationCode))
return SirsResult.NotSirsCode;
var cache = _redis.GetDatabase();
var key = SirsEvaluator.CriterionKey(encounterId, observationCode);
if (SirsEvaluator.MeetsCriterion(observationCode, value))
{
// SET with EX refreshes the TTL on every qualifying observation.
// A patient with tachycardia posting a reading every 60 seconds will keep
// sirs:{id}:HEART_RATE alive for 30 minutes after the LAST qualifying reading,
// not the first — the window slides forward with each new abnormal value.
await cache.StringSetAsync(key, "1", TimeSpan.FromSeconds(SirsTtlSeconds));
_logger.LogDebug("SIRS criterion set: {Key} (TTL={Ttl}s)", key, SirsTtlSeconds);
}
else
{
// Criterion no longer met — remove the key immediately rather than waiting
// for TTL expiry. If a patient's temperature normalises at 37.0 °C, the
// fever criterion must stop contributing to the count right away.
// Without this DEL, a recovered criterion could persist for up to 30 minutes
// and falsely sustain a SEPSIS_WARNING count.
await cache.KeyDeleteAsync(key);
_logger.LogDebug("SIRS criterion cleared: {Key}", key);
}
// Count active criteria in one MGET round-trip.
// MGET is O(N) where N = number of keys requested (4 here, always).
// Never use KEYS pattern for this check: KEYS scans the entire keyspace
// and blocks all other Redis operations until the scan completes.
var allKeys = SirsEvaluator.AllCriterionKeys(encounterId);
var values = await cache.StringGetAsync(allKeys);
var activeCount = values.Count(v => v.HasValue);
_logger.LogDebug(
"SIRS state for encounter {Id}: {Active}/4 criteria active after {Code}={Value}",
encounterId, activeCount, observationCode, value);
if (activeCount < 2)
return SirsResult.InsufficientCriteria(activeCount);
// Two or more criteria are active — attempt to create the alert.
var created = await TryCreateAlertAsync(encounterId, patientId, activeCount, ct);
return created ? SirsResult.AlertCreated : SirsResult.AlertAlreadyOpen;
}
// Creates the SEPSIS_WARNING alert and its outbox event in one atomic transaction.
// The INSERT WHERE NOT EXISTS pattern makes this safe under at-least-once delivery:
// if the consumer crashes after the INSERT but before committing the Kafka offset,
// the observation is reprocessed on restart. The second run hits the WHERE NOT EXISTS
// subquery, finds the existing open alert, inserts 0 rows, and returns false — no
// duplicate alert, no duplicate outbox event.
private async Task<bool> TryCreateAlertAsync(
Guid encounterId,
Guid patientId,
int activeCount,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await using var tx = await db.Database.BeginTransactionAsync(ct);
var alertId = Guid.NewGuid();
var triggeredAt = DateTimeOffset.UtcNow;
var details =
$"SIRS criteria met: {activeCount} of 4 criteria active within the 30-minute window.";
// One SQL round-trip: check + insert atomically.
// status IN ('OPEN', 'ESCALATED') prevents re-creating an alert that has been
// escalated but not yet resolved — the patient is still in danger.
var affected = await db.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO clinical_alerts
(id, encounter_id, patient_id, alert_type, severity, details, status, triggered_at)
SELECT {alertId}, {encounterId}, {patientId},
'SEPSIS_WARNING', 'CRITICAL', {details}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = 'SEPSIS_WARNING'
AND status IN ('OPEN', 'ESCALATED')
)
""", ct);
if (affected == 0)
{
await tx.RollbackAsync(ct);
_logger.LogDebug(
"SEPSIS_WARNING already open for encounter {Id} — no new alert", encounterId);
return false;
}
// Alert was created — write the outbox event in the same transaction.
// The relay (Phase 3) will publish to alert.generated, which Phase 6's
// notification worker reads to page the attending physician via RabbitMQ.
db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = "alert.generated",
Payload = JsonSerializer.Serialize(new
{
alertId,
encounterId,
patientId,
alertType = AlertType.SepsisWarning.ToDbString(),
severity = "Critical",
details,
triggeredAt,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_metrics.SirsDetectionsTotal.Inc();
_metrics.ClinicalAlertsTotal
.WithLabels(AlertType.SepsisWarning.ToDbString(), AlertSeverity.Critical.ToDbString())
.Inc();
using (LogContext.PushProperty("EncounterId", encounterId))
using (LogContext.PushProperty("PatientId", patientId))
{
_logger.LogWarning(
"SEPSIS_WARNING created. ActiveCriteriaCount={Count} AlertId={AlertId}",
activeCount, alertId);
}
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.SepsisWarning, ct);
return true;
}
}
@@ -1,38 +0,0 @@
using StackExchange.Redis;
public static class SirsEvaluator
{
// The four SIRS codes defined by this project's simplified SIRS criteria.
// Observations for any other code are ignored by the sepsis engine entirely —
// they pass through to the outbox and Elasticsearch but do not affect SIRS state.
public static readonly IReadOnlySet<string> SirsCodes =
new HashSet<string> { "TEMP_C", "HEART_RATE", "RESP_RATE", "WBC_K_UL" };
// Returns true if the observation value meets the SIRS criterion for its code.
// These thresholds are clinical parameters, not configuration — changing them
// requires clinical review, not a config file edit. They live here as named constants.
public static bool MeetsCriterion(string observationCode, decimal value) =>
observationCode switch
{
// Fever (> 38.3 °C) or hypothermia (< 36.0 °C)
"TEMP_C" => value > 38.3m || value < 36.0m,
// Tachycardia
"HEART_RATE" => value > 90m,
// Tachypnea
"RESP_RATE" => value > 20m,
// Leukocytosis or leukopenia
"WBC_K_UL" => value > 12.0m || value < 4.0m,
_ => false
};
// Redis key for one SIRS criterion for one encounter.
public static string CriterionKey(Guid encounterId, string code) =>
$"sirs:{encounterId}:{code}";
// All four Redis keys for one encounter — used in MGET to count active criteria.
// The order is stable so the MGET result array always maps to the same codes.
public static RedisKey[] AllCriterionKeys(Guid encounterId) =>
SirsCodes
.Select(code => (RedisKey)CriterionKey(encounterId, code))
.ToArray();
}
@@ -17,6 +17,10 @@ public class SepsisBundleService : ISepsisBundleService
public async Task<SepsisBundle?> TryCreateBundleAsync(
Guid encounterId, Guid triggeringAlertId, AlertType alertType, CancellationToken ct = default)
{
if (alertType != AlertType.SofaSepsis)
throw new InvalidOperationException(
$"Sepsis bundle can only be triggered by SOFA_SEPSIS, not {alertType.ToDbString()}.");
var exists = await _db.SepsisBundles
.AnyAsync(b => b.EncounterId == encounterId
&& b.ComplianceStatus == SepsisBundleComplianceStatus.InProgress, ct);
@@ -276,6 +276,8 @@ public class SofaDetector
return false;
}
AlertCreationGuard.EnsureAllowed(alertType);
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -332,6 +334,12 @@ public class SofaDetector
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
if (alertType == AlertType.SofaSepsis)
{
var handler = _services.GetRequiredService<SepsisAlertHandler>();
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, alertType, ct);
}
_metrics.ClinicalAlertsTotal
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();