Files
voltsrage 7751d6df06 fix:
No SOFA trend chart or organ-system timeline
No GCS trend chart or component history
No qSOFA history view
2026-06-23 18:00:43 +08:00

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);
}
}