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 _logger; private readonly ClinicalMetrics _metrics; public QsofaDetector( IConnectionMultiplexer redis, IServiceProvider services, ILogger logger, ClinicalMetrics metrics) { _redis = redis; _services = services; _logger = logger; _metrics = metrics; } public async Task 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 TryCreateAlertAsync( Guid encounterId, Guid patientId, int activeCount, RedisValue[] criterionValues, CancellationToken ct) { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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(); await handler.OnSepsisAlertCreatedAsync(encounterId, alertId, AlertType.QsofaWarning, ct); return true; } private static string BuildDetails(int activeCount, RedisValue[] values) { var activeParts = new List(); 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)}"; } }