using System.Globalization; using System.Text.Json; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; public class GcsDetector { private const int GcsTtlSeconds = 14400; // 4 hours — same as NEWS2 private readonly IConnectionMultiplexer _redis; private readonly IServiceProvider _services; private readonly ClinicalMetrics _metrics; private readonly ILogger _logger; public GcsDetector( IConnectionMultiplexer redis, IServiceProvider services, ClinicalMetrics metrics, ILogger logger) { _redis = redis; _services = services; _metrics = metrics; _logger = logger; } public async Task ProcessObservationAsync( Guid encounterId, Guid patientId, string observationCode, decimal value, CancellationToken ct = default) { if (!GcsCalculator.IsGcsCode(observationCode)) return GcsResult.NotGcsCode; var cache = _redis.GetDatabase(); await cache.StringSetAsync( GcsCalculator.ComponentKey(encounterId, observationCode), value.ToString(CultureInfo.InvariantCulture), TimeSpan.FromSeconds(GcsTtlSeconds)); var allKeys = GcsCalculator.AllComponentKeys(encounterId); var allValues = await cache.StringGetAsync(allKeys); if (allValues.Any(v => !v.HasValue)) { var present = allValues.Count(v => v.HasValue); _logger.LogDebug( "GCS incomplete for encounter {Id}: {Present}/3 components present", encounterId, present); return GcsResult.IncompleteComponents(present); } var eye = decimal.Parse(allValues[0]!, CultureInfo.InvariantCulture); var verbal = decimal.Parse(allValues[1]!, CultureInfo.InvariantCulture); var motor = decimal.Parse(allValues[2]!, CultureInfo.InvariantCulture); var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value; var classification = GcsCalculator.ClassifyGcs(total); var calculatedAt = DateTimeOffset.UtcNow; await PersistScoreAsync( encounterId, patientId, (int)eye, (int)verbal, (int)motor, total, classification, calculatedAt, ct); var alertCreated = false; if (total <= 8) { alertCreated = await TryCreateAlertAsync( encounterId, patientId, AlertType.GcsCritical, AlertSeverity.Critical, eye, verbal, motor, total, classification, ct); } else if (total <= 12) { alertCreated = await TryCreateAlertAsync( encounterId, patientId, AlertType.GcsWarning, AlertSeverity.Warning, eye, verbal, motor, total, classification, ct); } await PublishScoredEventAsync( encounterId, patientId, eye, verbal, motor, total, classification, calculatedAt, ct); // Re-evaluate qSOFA altered mentation from the computed GCS total (Step 6) using (var scope = _services.CreateScope()) { var qsofa = scope.ServiceProvider.GetRequiredService(); await qsofa.SyncAlteredMentationAsync(encounterId, patientId, ct); } _metrics.GcsScoresTotal.WithLabels(classification).Inc(); _logger.LogInformation( "GCS score {Total} ({Classification}) for encounter {Id} — E={Eye} V={Verbal} M={Motor}", total, classification, encounterId, eye, verbal, motor); return new GcsResult(GcsOutcome.ScoreComputed, total, classification, alertCreated, 3); } private async Task PersistScoreAsync( Guid encounterId, Guid patientId, int eye, int verbal, int motor, int total, string classification, DateTimeOffset calculatedAt, CancellationToken ct) { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.GcsScores.Add(new GcsScore { Id = Guid.NewGuid(), EncounterId = encounterId, PatientId = patientId, EyeScore = eye, VerbalScore = verbal, MotorScore = motor, TotalScore = total, Classification = classification, CalculatedAt = calculatedAt, CreatedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(ct); } private async Task TryCreateAlertAsync( Guid encounterId, Guid patientId, AlertType alertType, AlertSeverity severity, decimal eye, decimal verbal, decimal motor, int total, string classification, CancellationToken ct) { if (alertType == AlertType.GcsWarning) { var suppression = _services.GetRequiredService(); if (await suppression.IsSuppressedAsync(encounterId, alertType, ct)) { _logger.LogDebug("GCS_WARNING suppressed for encounter {Id}", encounterId); return false; } } 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 = $"GCS total {total} ({classification}): E={eye}, V={verbal}, M={motor}."; 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}, {alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt} WHERE NOT EXISTS ( SELECT 1 FROM clinical_alerts WHERE encounter_id = {encounterId} AND alert_type = {alertType.ToDbString()} 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.ToDbString(), severity = severity.ToDbString(), details, triggeredAt, gcsTotal = total, gcsClassification = classification, partitionKey = encounterId.ToString() }), PartitionKey = encounterId.ToString(), CreatedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(ct); await tx.CommitAsync(ct); _metrics.ClinicalAlertsTotal .WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc(); return true; } private async Task PublishScoredEventAsync( Guid encounterId, Guid patientId, decimal eye, decimal verbal, decimal motor, int total, string classification, DateTimeOffset calculatedAt, CancellationToken ct) { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), Topic = "gcs.scored", Payload = JsonSerializer.Serialize(new { encounterId, patientId, eyeScore = (int)eye, verbalScore = (int)verbal, motorScore = (int)motor, totalScore = total, classification, calculatedAt, partitionKey = encounterId.ToString() }), PartitionKey = encounterId.ToString(), CreatedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(ct); } }