feature: qSOFA Scoring & Sepsis Bundle Compliance
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
public static class QsofaCalculator
|
||||
{
|
||||
public static readonly IReadOnlyList<string> QsofaCodes = new[]
|
||||
{
|
||||
"RESP_RATE", "SYSTOLIC_BP", "AVPU"
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> QsofaCodeSet =
|
||||
new HashSet<string>(QsofaCodes);
|
||||
|
||||
// qSOFA criteria (Sepsis-3 consensus):
|
||||
// - Respiratory rate ≥ 22 breaths/min
|
||||
// - Systolic blood pressure ≤ 100 mmHg
|
||||
// - Altered mentation: AVPU score ≥ 1 (any non-Alert state)
|
||||
public static bool MeetsCriterion(string observationCode, decimal value) =>
|
||||
observationCode switch
|
||||
{
|
||||
"RESP_RATE" => value >= 22m,
|
||||
"SYSTOLIC_BP" => value <= 100m,
|
||||
"AVPU" => value >= 1m,
|
||||
_ => false
|
||||
};
|
||||
|
||||
public static string CriterionKey(Guid encounterId, string code) =>
|
||||
$"qsofa:{encounterId}:{code}";
|
||||
|
||||
public static RedisKey[] AllCriterionKeys(Guid encounterId) =>
|
||||
QsofaCodes.Select(c => (RedisKey)CriterionKey(encounterId, c)).ToArray();
|
||||
|
||||
public static int CountActiveCriteria(RedisValue[] values) =>
|
||||
values.Count(v => v.HasValue);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog.Context;
|
||||
using StackExchange.Redis;
|
||||
|
||||
public class QsofaDetector
|
||||
{
|
||||
// 30 minutes in seconds — same sliding window as SIRS.
|
||||
private const int QsofaTtlSeconds = 1800;
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<QsofaDetector> _logger;
|
||||
private readonly ClinicalMetrics _metrics;
|
||||
|
||||
public QsofaDetector(
|
||||
IConnectionMultiplexer redis,
|
||||
IServiceProvider services,
|
||||
ILogger<QsofaDetector> logger,
|
||||
ClinicalMetrics metrics)
|
||||
{
|
||||
_redis = redis;
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task<QsofaResult> ProcessObservationAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
string observationCode,
|
||||
decimal value,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!QsofaCalculator.QsofaCodeSet.Contains(observationCode))
|
||||
return QsofaResult.NotQsofaCode;
|
||||
|
||||
var cache = _redis.GetDatabase();
|
||||
var key = QsofaCalculator.CriterionKey(encounterId, observationCode);
|
||||
|
||||
if (QsofaCalculator.MeetsCriterion(observationCode, value))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
private async Task<bool> TryCreateAlertAsync(
|
||||
Guid encounterId,
|
||||
Guid patientId,
|
||||
int activeCount,
|
||||
RedisValue[] criterionValues,
|
||||
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 = BuildDetails(activeCount, criterionValues);
|
||||
|
||||
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}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM clinical_alerts
|
||||
WHERE encounter_id = {encounterId}
|
||||
AND alert_type = 'QSOFA_WARNING'
|
||||
AND status IN ('OPEN', 'ESCALATED')
|
||||
)
|
||||
""", ct);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
await tx.RollbackAsync(ct);
|
||||
_logger.LogDebug(
|
||||
"QSOFA_WARNING already open for encounter {Id} — no new alert", encounterId);
|
||||
return false;
|
||||
}
|
||||
|
||||
db.OutboxEvents.Add(new OutboxEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Topic = "alert.generated",
|
||||
Payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
alertId,
|
||||
encounterId,
|
||||
patientId,
|
||||
alertType = AlertType.QsofaWarning.ToDbString(),
|
||||
severity = "Critical",
|
||||
details,
|
||||
triggeredAt,
|
||||
partitionKey = encounterId.ToString()
|
||||
}),
|
||||
PartitionKey = encounterId.ToString(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
_metrics.QsofaDetectionsTotal.Inc();
|
||||
_metrics.ClinicalAlertsTotal
|
||||
.WithLabels(AlertType.QsofaWarning.ToDbString(), AlertSeverity.Critical.ToDbString())
|
||||
.Inc();
|
||||
|
||||
using (LogContext.PushProperty("EncounterId", encounterId))
|
||||
using (LogContext.PushProperty("PatientId", patientId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"QSOFA_WARNING created. ActiveCriteriaCount={Count} AlertId={AlertId}",
|
||||
activeCount, alertId);
|
||||
}
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
|
||||
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.QsofaWarning, ct);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string BuildDetails(int activeCount, RedisValue[] values)
|
||||
{
|
||||
var activeParts = new List<string>();
|
||||
for (var i = 0; i < QsofaCalculator.QsofaCodes.Count; i++)
|
||||
{
|
||||
if (values[i].HasValue)
|
||||
activeParts.Add($"{QsofaCalculator.QsofaCodes[i]}={values[i]}");
|
||||
}
|
||||
|
||||
return $"qSOFA score {activeCount}/3: {string.Join(", ", activeParts)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
public class SepsisAlertHandler
|
||||
{
|
||||
private readonly ISepsisBundleService _bundleService;
|
||||
private readonly ILogger<SepsisAlertHandler> _logger;
|
||||
|
||||
public SepsisAlertHandler(ISepsisBundleService bundleService, ILogger<SepsisAlertHandler> logger)
|
||||
{
|
||||
_bundleService = bundleService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task OnSepsisAlertCreatedAsync(
|
||||
Guid encounterId, Guid alertId, AlertType alertType, CancellationToken ct)
|
||||
{
|
||||
var bundle = await _bundleService.TryCreateBundleAsync(encounterId, alertId, alertType, ct);
|
||||
|
||||
if (bundle is not null)
|
||||
_logger.LogInformation(
|
||||
"Sepsis bundle {BundleId} created for encounter {EncounterId} (trigger={AlertType})",
|
||||
bundle.Id, encounterId, alertType.ToDbString());
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,9 @@ public class SirsDetector
|
||||
activeCount, alertId);
|
||||
}
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
|
||||
await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.SepsisWarning, ct);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user