Files
vigilcare-clinical/VigilCareClinicalAPI/News2/News2Detector.cs
T

234 lines
8.4 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Prometheus;
using StackExchange.Redis;
public class News2Detector
{
private const int News2TtlSeconds = 14400; // 4 hours
private static readonly JsonSerializerOptions CachedParamJsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly IConnectionMultiplexer _redis;
private readonly IServiceProvider _services;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<News2Detector> _logger;
public News2Detector(
IConnectionMultiplexer redis,
IServiceProvider services,
ClinicalMetrics metrics,
ILogger<News2Detector> logger)
{
_redis = redis;
_services = services;
_metrics = metrics;
_logger = logger;
}
public async Task<News2Result> ProcessObservationAsync(
Guid encounterId,
Guid patientId,
string observationCode,
decimal value,
CancellationToken ct = default)
{
if (!News2Calculator.IsNews2Code(observationCode))
return News2Result.NotNews2Code;
using var timer = _metrics.News2ScoringDuration.NewTimer();
var cache = _redis.GetDatabase();
// Compute the individual score and store in Redis
var individualScore = News2Calculator.ScoreParameter(observationCode, value);
var paramData = JsonSerializer.Serialize(new
{
value,
score = individualScore,
recordedAt = DateTimeOffset.UtcNow
}, CachedParamJsonOptions);
await cache.StringSetAsync(
News2Calculator.ParameterKey(encounterId, observationCode),
paramData,
TimeSpan.FromSeconds(News2TtlSeconds));
// Fetch all 7 parameter keys in one MGET round-trip
var allKeys = News2Calculator.AllParameterKeys(encounterId);
var allValues = await cache.StringGetAsync(allKeys);
// Check completeness — all 7 must be present
var scores = new int?[7];
for (int i = 0; i < 7; i++)
{
if (!allValues[i].HasValue)
{
_logger.LogDebug(
"NEWS2 incomplete for encounter {Id}: {Code} missing ({Present}/7 present)",
encounterId, News2Calculator.ParameterCodes[i],
allValues.Count(v => v.HasValue));
return News2Result.IncompleteParameters(allValues.Count(v => v.HasValue));
}
var cached = JsonSerializer.Deserialize<News2CachedParam>(allValues[i]!, CachedParamJsonOptions);
scores[i] = cached?.Score;
}
// All 7 present — compute aggregate
var paramScores = scores.Select(s => s!.Value).ToArray();
var totalScore = paramScores.Sum();
var hasSingleParamThree = paramScores.Any(s => s == 3);
var riskLevel = News2Calculator.DetermineRiskLevel(totalScore, hasSingleParamThree);
// Persist the score to PostgreSQL
var scoreId = await PersistScoreAsync(
encounterId, patientId, totalScore, riskLevel,
paramScores, hasSingleParamThree, ct);
_logger.LogInformation(
"NEWS2 score {Score} ({Risk}) for encounter {Id} — components: {Components}",
totalScore, riskLevel, encounterId,
string.Join(",", News2Calculator.ParameterCodes.Zip(paramScores, (c, s) => $"{c}={s}")));
// Create alert if warranted
var alertCreated = false;
if (riskLevel == "HIGH")
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.News2Emergency, AlertSeverity.Critical,
totalScore, riskLevel, paramScores, ct);
}
else if (riskLevel == "MEDIUM" || riskLevel == "LOW_MEDIUM")
{
alertCreated = await TryCreateAlertAsync(
encounterId, patientId, AlertType.News2Warning, AlertSeverity.Warning,
totalScore, riskLevel, paramScores, ct);
}
return new News2Result(
News2Outcome.ScoreComputed, totalScore, riskLevel, alertCreated, 7, hasSingleParamThree);
}
private async Task<Guid> PersistScoreAsync(
Guid encounterId, Guid patientId,
int totalScore, string riskLevel,
int[] paramScores, bool hasSingleParamThree,
CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var score = new News2Score
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
TotalScore = totalScore,
RiskLevel = riskLevel,
RespRateScore = paramScores[0],
Spo2Score = paramScores[1],
SystolicBpScore = paramScores[2],
HeartRateScore = paramScores[3],
ConsciousnessScore = paramScores[4],
TemperatureScore = paramScores[5],
SupplementalO2Score = paramScores[6],
HasSingleParamThree = hasSingleParamThree,
CalculatedAt = DateTimeOffset.UtcNow
};
db.News2Scores.Add(score);
await db.SaveChangesAsync(ct);
_metrics.News2ScoresTotal.WithLabels(riskLevel).Inc();
return score.Id;
}
private async Task<bool> TryCreateAlertAsync(
Guid encounterId, Guid patientId,
AlertType alertType, AlertSeverity severity,
int totalScore, string riskLevel, int[] paramScores,
CancellationToken ct)
{
if (alertType == AlertType.News2Warning)
{
var suppression = _services.GetRequiredService<IAlertSuppressionService>();
if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
{
_logger.LogDebug("NEWS2_WARNING suppressed for encounter {Id}", encounterId);
return false;
}
}
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 details = BuildDetails(totalScore, riskLevel, paramScores);
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},
{alertType.ToDbString()}, {severity.ToDbString()}, {details}, 'OPEN', {triggeredAt}
WHERE NOT EXISTS (
SELECT 1 FROM clinical_alerts
WHERE encounter_id = {encounterId}
AND alert_type = {alertType.ToDbString()}
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.ToDbString(),
severity = severity.ToDbString(),
triggeredAt,
news2Score = totalScore,
news2RiskLevel = riskLevel,
partitionKey = encounterId.ToString()
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
_metrics.ClinicalAlertsTotal
.WithLabels(alertType.ToDbString(), severity.ToDbString()).Inc();
_logger.LogWarning(
"NEWS2 alert {AlertType} created for encounter {EncounterId} — score={Score} risk={Risk}",
alertType.ToDbString(), encounterId, totalScore, riskLevel);
return true;
}
private static string BuildDetails(int totalScore, string riskLevel, int[] paramScores)
{
var components = News2Calculator.ParameterCodes
.Zip(paramScores, (code, score) => $"{code}={score}")
.ToArray();
return $"NEWS2 score {totalScore} ({riskLevel}): {string.Join(", ", components)}.";
}
}