No SOFA trend chart or organ-system timeline No GCS trend chart or component history No qSOFA history view
234 lines
7.9 KiB
C#
234 lines
7.9 KiB
C#
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<QsofaDetector> _logger;
|
|
private readonly ClinicalMetrics _metrics;
|
|
|
|
public QsofaDetector(
|
|
IConnectionMultiplexer redis,
|
|
IServiceProvider services,
|
|
ILogger<QsofaDetector> logger,
|
|
ClinicalMetrics metrics)
|
|
{
|
|
_redis = redis;
|
|
_services = services;
|
|
_logger = logger;
|
|
_metrics = metrics;
|
|
}
|
|
|
|
public async Task<QsofaResult> 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<QsofaResult> 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<QsofaResult> 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);
|
|
|
|
var created = activeCount >= 2
|
|
&& await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct);
|
|
|
|
await PersistEvaluationIfChangedAsync(
|
|
encounterId, patientId, activeCount, values, created, ct);
|
|
|
|
if (activeCount < 2)
|
|
return QsofaResult.InsufficientCriteria(activeCount);
|
|
|
|
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
|
|
}
|
|
|
|
private async Task PersistEvaluationIfChangedAsync(
|
|
Guid encounterId,
|
|
Guid patientId,
|
|
int activeCount,
|
|
RedisValue[] values,
|
|
bool screenAlertFired,
|
|
CancellationToken ct)
|
|
{
|
|
using var scope = _services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var last = await db.QsofaEvaluations
|
|
.AsNoTracking()
|
|
.Where(e => e.EncounterId == encounterId)
|
|
.OrderByDescending(e => e.EvaluatedAt)
|
|
.ThenByDescending(e => e.Id)
|
|
.Select(e => new { e.ActiveCriteria, e.ScreenAlertFired })
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
if (last is not null
|
|
&& last.ActiveCriteria == activeCount
|
|
&& !screenAlertFired)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var criteria = QsofaCalculator.ParseCriteriaState(values);
|
|
var evaluatedAt = DateTimeOffset.UtcNow;
|
|
|
|
db.QsofaEvaluations.Add(new QsofaEvaluation
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EncounterId = encounterId,
|
|
PatientId = patientId,
|
|
ActiveCriteria = activeCount,
|
|
RespRate = criteria.RespRate,
|
|
SystolicBp = criteria.SystolicBp,
|
|
Avpu = criteria.Avpu,
|
|
ScreenAlertFired = screenAlertFired,
|
|
EvaluatedAt = evaluatedAt,
|
|
CreatedAt = evaluatedAt,
|
|
});
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
private async Task<bool> 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<AppDbContext>();
|
|
|
|
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<string>();
|
|
for (var i = 0; i < QsofaCalculator.QsofaCodes.Count; i++)
|
|
{
|
|
if (values[i].HasValue)
|
|
activeParts.Add($"{QsofaCalculator.QsofaCodes[i]}={values[i]}");
|
|
}
|
|
return string.Join(", ", activeParts);
|
|
}
|
|
} |