using System.Text.Json; using Microsoft.EntityFrameworkCore; using Serilog.Context; using StackExchange.Redis; public class QsofaDetector { 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)); } else { await cache.KeyDeleteAsync(key); } return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct); } public async Task SyncAlteredMentationAsync( Guid encounterId, Guid patientId, CancellationToken ct = default) { var cache = _redis.GetDatabase(); var avpuKey = QsofaCalculator.CriterionKey(encounterId, "AVPU"); var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId)); if (gcsValues.All(v => v.HasValue)) { var eye = decimal.Parse(gcsValues[0]!); var verbal = decimal.Parse(gcsValues[1]!); var motor = decimal.Parse(gcsValues[2]!); var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value; if (QsofaCalculator.MeetsGcsAlteredMentation(total)) await cache.StringSetAsync(avpuKey, "1", TimeSpan.FromSeconds(QsofaTtlSeconds)); else await cache.KeyDeleteAsync(avpuKey); } return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct); } private async Task 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); if (activeCount < 2) return QsofaResult.InsufficientCriteria(activeCount); var created = await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct); return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen; } private async Task 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(); await using var tx = await db.Database.BeginTransactionAsync(ct); var alertId = Guid.NewGuid(); var triggeredAt = DateTimeOffset.UtcNow; 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_SCREEN', 'WARNING', {details}, 'OPEN', {triggeredAt} WHERE NOT EXISTS ( SELECT 1 FROM clinical_alerts WHERE encounter_id = {encounterId} AND alert_type = 'QSOFA_SCREEN' AND status IN ('OPEN', 'ESCALATED') ) """, ct); if (affected == 0) { await tx.RollbackAsync(ct); return false; } db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), Topic = "alert.generated", Payload = JsonSerializer.Serialize(new { alertId, encounterId, patientId, alertType = AlertType.QsofaScreen.ToDbString(), severity = AlertSeverity.Warning.ToDbString(), 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.QsofaScreen.ToDbString(), AlertSeverity.Warning.ToDbString()) .Inc(); using (LogContext.PushProperty("EncounterId", encounterId)) using (LogContext.PushProperty("PatientId", patientId)) { _logger.LogWarning( "QSOFA_SCREEN created. ActiveCriteriaCount={Count} AlertId={AlertId}", activeCount, alertId); } // Screening alert — does NOT trigger sepsis bundle (Phase 27 Step 4) return true; } private static string BuildActiveCriteriaList(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 string.Join(", ", activeParts); } }