237 lines
8.2 KiB
C#
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);
|
|
}
|
|
} |