feature: qSOFA Scoring & Sepsis Bundle Compliance
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user