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
+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);
}
}
}