Files
vigilcare-clinical/VigilCareClinicalAPI/Gcs/GcsDetector.cs
T
voltsrage bf46e6554a feature: Sepsis Engine Refactor: Remove SIRS, Rewire qSOFA + Bundle
Frontend: GCS Entry, SOFA Display, Sepsis UI Refactor
2026-06-21 03:56:27 +08:00

237 lines
8.2 KiB
C#

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<GcsDetector> _logger;
public GcsDetector(
IConnectionMultiplexer redis,
IServiceProvider services,
ClinicalMetrics metrics,
ILogger<GcsDetector> logger)
{
_redis = redis;
_services = services;
_metrics = metrics;
_logger = logger;
}
public async Task<GcsResult> 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<QsofaDetector>();
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<AppDbContext>();
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<bool> 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<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
{
_logger.LogDebug("GCS_WARNING suppressed for encounter {Id}", encounterId);
return false;
}
}
AlertCreationGuard.EnsureAllowed(alertType);
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 =
$"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<AppDbContext>();
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);
}
}