feature: qSOFA Scoring & Sepsis Bundle Compliance

This commit is contained in:
voltsrage
2026-06-18 22:52:43 +08:00
parent 3c54feb38a
commit 4b46c09f90
34 changed files with 2351 additions and 18 deletions
@@ -47,10 +47,12 @@ public class EsIndexerService : BackgroundService
{
_kafkaOptions.Topics.ObservationRecorded,
_kafkaOptions.Topics.AlertGenerated,
_kafkaOptions.Topics.EncounterStatusChanged
_kafkaOptions.Topics.EncounterStatusChanged,
_kafkaOptions.Topics.SepsisBundleCreated,
_kafkaOptions.Topics.SepsisBundleUpdated
});
_logger.LogInformation("EsIndexerService started. Subscribed to 3 topics.");
_logger.LogInformation("EsIndexerService started. Subscribed to 5 topics.");
try
{
@@ -91,6 +93,10 @@ public class EsIndexerService : BackgroundService
HandleObservationRecordedAsync(payload, ct),
var t when t == _kafkaOptions.Topics.AlertGenerated =>
HandleAlertGeneratedAsync(payload, ct),
var t when t == _kafkaOptions.Topics.SepsisBundleCreated =>
HandleSepsisBundleEventAsync(payload, ct),
var t when t == _kafkaOptions.Topics.SepsisBundleUpdated =>
HandleSepsisBundleEventAsync(payload, ct),
_ => Task.CompletedTask
};
@@ -260,4 +266,63 @@ public class EsIndexerService : BackgroundService
_logger.LogWarning(
"Could not update patient_encounters for alert on encounter {Id}", evt.EncounterId);
}
// --- sepsis.bundle.created / sepsis.bundle.updated ---
// Denormalizes bundle status onto the patient_encounters document so department
// acuity dashboards can filter/sort by sepsis bundle compliance state.
private async Task HandleSepsisBundleEventAsync(string payload, CancellationToken ct)
{
using var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
var encounterId = root.GetProperty("encounterId").GetString()!;
var complianceStatus = root.TryGetProperty("complianceStatus", out var cs)
? cs.GetString() ?? "IN_PROGRESS"
: "IN_PROGRESS";
var deadlineAt = root.TryGetProperty("deadlineAt", out var dl) ? dl.GetString() : null;
var scriptLines = new List<string>
{
"ctx._source.sepsisBundleStatus = params.status",
"ctx._source.sepsisBundleDeadlineAt = params.deadlineAt"
};
var scriptParams = new Dictionary<string, object?>
{
["status"] = complianceStatus,
["deadlineAt"] = deadlineAt
};
if (root.TryGetProperty("elementStatus", out _))
{
scriptLines.Add("ctx._source.sepsisBundleElementsCompleted = params.elementsCompleted");
scriptParams["elementsCompleted"] = root.TryGetProperty("elementsCompleted", out var ec)
? ec.GetInt32()
: (complianceStatus is "COMPLIANT" or "NON_COMPLIANT" ? 4 : 0);
}
else
{
scriptLines.Add("ctx._source.sepsisBundleElementsCompleted = 0");
}
var updateResp = await _elastic.UpdateAsync<PatientEncounterDocument, object>(
_esOptions.Indices.PatientEncounters,
encounterId,
u => u
.Script(new Script(new InlineScript
{
Source = string.Join(";\n", scriptLines),
Language = ScriptLanguage.Painless,
Params = scriptParams!
}))
.RetryOnConflict(3),
ct);
if (!updateResp.IsValidResponse && updateResp.Result != Result.NotFound)
_logger.LogWarning(
"Could not update patient_encounters bundle status for encounter {Id}", encounterId);
_logger.LogDebug("Updated patient_encounters bundle status for encounter {Id} → {Status}",
encounterId, complianceStatus);
}
}
@@ -25,7 +25,9 @@ public class KafkaTopicProvisioner : IHostedService
_options.Topics.ObservationRecorded,
_options.Topics.AlertGenerated,
_options.Topics.AlertAcknowledged,
_options.Topics.EncounterStatusChanged
_options.Topics.EncounterStatusChanged,
_options.Topics.SepsisBundleCreated,
_options.Topics.SepsisBundleUpdated
};
var specs = topicNames.Select(name => new TopicSpecification
@@ -54,24 +54,38 @@ public class SepsisEngineService : BackgroundService
var evt = JsonSerializer.Deserialize<SepsisObservationEvent>(
result.Message.Value, EventJsonOptions)!;
// Create a scope per message — SirsDetector is scoped and
// owns a fresh DbContext for each observation processed.
// Create a scope per message — both detectors are scoped and
// each owns a fresh DbContext when creating alerts.
using var scope = _services.CreateScope();
var detector = scope.ServiceProvider.GetRequiredService<SirsDetector>();
var sirsDetector = scope.ServiceProvider.GetRequiredService<SirsDetector>();
var qsofaDetector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var outcome = await detector.ProcessObservationAsync(
var sirsOutcome = await sirsDetector.ProcessObservationAsync(
evt.EncounterId,
evt.PatientId,
evt.ObservationCode,
evt.Value,
stoppingToken);
if (outcome.Outcome == SirsOutcome.AlertCreated)
var qsofaOutcome = await qsofaDetector.ProcessObservationAsync(
evt.EncounterId,
evt.PatientId,
evt.ObservationCode,
evt.Value,
stoppingToken);
if (sirsOutcome.Outcome == SirsOutcome.AlertCreated)
_logger.LogWarning(
"SEPSIS_WARNING created via SepsisEngine " +
"— encounter={EncounterId} code={Code} value={Value}",
evt.EncounterId, evt.ObservationCode, evt.Value);
if (qsofaOutcome.Outcome == QsofaOutcome.AlertCreated)
_logger.LogWarning(
"QSOFA_WARNING created via SepsisEngine " +
"— encounter={EncounterId} code={Code} value={Value}",
evt.EncounterId, evt.ObservationCode, evt.Value);
// Commit only after successful processing.
consumer.Commit(result);
}