feature:
Full SOFA Score: Data Layer + Scoring Engine Glasgow Coma Scale: Data Layer + Scoring Engine
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user