feature: NEWS2 Composite Scoring Engine
This commit is contained in:
@@ -58,8 +58,10 @@ public class ElasticIndexProvisioner : IHostedService
|
||||
.Keyword(k => k.Department)
|
||||
.Keyword(k => k.Status)
|
||||
.Keyword(k => k.AttendingPhysician)
|
||||
.Keyword(k => k.RoomBed)
|
||||
.Text(t => t.AdmissionReason)
|
||||
.Keyword(k => k.RoomBed!)
|
||||
.Text(t => t.AdmissionReason!)
|
||||
.IntegerNumber(i => i.News2Score!)
|
||||
.Keyword(k => k.News2RiskLevel!)
|
||||
.Date(d => d.AdmittedAt)
|
||||
.IntegerNumber(i => i.OpenAlertCount)
|
||||
.Date(d => d.LastObservationAt!)
|
||||
|
||||
@@ -201,8 +201,10 @@ public class EsIndexerService : BackgroundService
|
||||
private async Task HandleAlertGeneratedAsync(string payload, CancellationToken ct)
|
||||
{
|
||||
var evt = JsonSerializer.Deserialize<AlertGeneratedEvent>(payload, EventJsonOptions)!;
|
||||
using var payloadDoc = JsonDocument.Parse(payload);
|
||||
var root = payloadDoc.RootElement;
|
||||
|
||||
var doc = new ClinicalAlertDocument
|
||||
var alertDoc = new ClinicalAlertDocument
|
||||
{
|
||||
AlertId = evt.AlertId.ToString(),
|
||||
EncounterId = evt.EncounterId.ToString(),
|
||||
@@ -215,29 +217,47 @@ public class EsIndexerService : BackgroundService
|
||||
};
|
||||
|
||||
var indexResp = await _elastic.IndexAsync(
|
||||
doc,
|
||||
i => i.Index(_esOptions.Indices.ClinicalAlerts).Id(doc.AlertId),
|
||||
alertDoc,
|
||||
i => i.Index(_esOptions.Indices.ClinicalAlerts).Id(alertDoc.AlertId),
|
||||
ct);
|
||||
|
||||
if (!indexResp.IsValidResponse)
|
||||
throw new InvalidOperationException(
|
||||
$"ES index failed for alert {evt.AlertId}: {indexResp.DebugInformation}");
|
||||
|
||||
// Increment openAlertCount on the parent encounter document
|
||||
// Increment openAlertCount on the parent encounter document.
|
||||
// NEWS2 alerts also carry news2Score/news2RiskLevel in the Kafka payload;
|
||||
// stamp those on patient_encounters so ward dashboards can filter by acuity.
|
||||
var scriptLines = new List<string> { "ctx._source.openAlertCount += 1" };
|
||||
Dictionary<string, object>? scriptParams = null;
|
||||
|
||||
if (root.TryGetProperty("news2Score", out var scoreElem) &&
|
||||
root.TryGetProperty("news2RiskLevel", out var riskElem))
|
||||
{
|
||||
scriptLines.Add("ctx._source.news2Score = params.score");
|
||||
scriptLines.Add("ctx._source.news2RiskLevel = params.riskLevel");
|
||||
scriptParams = new Dictionary<string, object>
|
||||
{
|
||||
["score"] = scoreElem.GetInt32(),
|
||||
["riskLevel"] = riskElem.GetString()!
|
||||
};
|
||||
}
|
||||
|
||||
var updateResp = await _elastic.UpdateAsync<PatientEncounterDocument, object>(
|
||||
_esOptions.Indices.PatientEncounters,
|
||||
evt.EncounterId.ToString(),
|
||||
u => u
|
||||
.Script(new Script(new InlineScript
|
||||
{
|
||||
Source = "ctx._source.openAlertCount += 1",
|
||||
Language = ScriptLanguage.Painless
|
||||
Source = string.Join(";\n", scriptLines),
|
||||
Language = ScriptLanguage.Painless,
|
||||
Params = scriptParams
|
||||
}))
|
||||
.RetryOnConflict(3),
|
||||
ct);
|
||||
|
||||
if (!updateResp.IsValidResponse && updateResp.Result != Result.NotFound)
|
||||
_logger.LogWarning(
|
||||
"Could not increment openAlertCount for encounter {Id}", evt.EncounterId);
|
||||
"Could not update patient_encounters for alert on encounter {Id}", evt.EncounterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using Confluent.Kafka;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public class News2ScoringService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly KafkaOptions _kafkaOptions;
|
||||
private readonly ILogger<News2ScoringService> _logger;
|
||||
|
||||
public News2ScoringService(
|
||||
IServiceProvider services,
|
||||
IOptions<KafkaOptions> kafkaOptions,
|
||||
ILogger<News2ScoringService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_kafkaOptions = kafkaOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var config = new ConsumerConfig
|
||||
{
|
||||
BootstrapServers = _kafkaOptions.BootstrapServers,
|
||||
GroupId = "news2-scoring",
|
||||
AutoOffsetReset = AutoOffsetReset.Earliest,
|
||||
EnableAutoCommit = false
|
||||
};
|
||||
|
||||
using var consumer = new ConsumerBuilder<string, string>(config).Build();
|
||||
consumer.Subscribe(_kafkaOptions.Topics.ObservationRecorded);
|
||||
|
||||
_logger.LogInformation("News2ScoringService started — consumer group: news2-scoring");
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
ConsumeResult<string, string>? result = null;
|
||||
try
|
||||
{
|
||||
result = consumer.Consume(stoppingToken);
|
||||
|
||||
var evt = JsonSerializer.Deserialize<News2ObservationEvent>(
|
||||
result.Message.Value,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var detector = scope.ServiceProvider.GetRequiredService<News2Detector>();
|
||||
|
||||
var outcome = await detector.ProcessObservationAsync(
|
||||
evt.EncounterId,
|
||||
evt.PatientId,
|
||||
evt.ObservationCode,
|
||||
evt.Value,
|
||||
stoppingToken);
|
||||
|
||||
if (outcome.Outcome == News2Outcome.ScoreComputed)
|
||||
_logger.LogInformation(
|
||||
"NEWS2 scored via consumer — encounter={Id} score={Score} risk={Risk}",
|
||||
evt.EncounterId, outcome.TotalScore, outcome.RiskLevel);
|
||||
|
||||
consumer.Commit(result);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"News2ScoringService failed on topic={Topic} offset={Offset} — not committing",
|
||||
result?.Topic, result?.Offset.Value);
|
||||
await Task.Delay(2000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
consumer.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user