using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Prometheus; using StackExchange.Redis; public class TrendDetector { private readonly IConnectionMultiplexer _redis; private readonly IServiceProvider _services; private readonly TrendDetectionOptions _options; private readonly ClinicalMetrics _metrics; private readonly ILogger _logger; public TrendDetector( ILogger logger, ClinicalMetrics metrics, IServiceProvider services, IConnectionMultiplexer redis, IOptions options) { _logger = logger; _metrics = metrics; _services = services; _redis = redis; _options = options.Value; } public async Task ProcessObservationAsync( Guid encounterId, Guid patientId, string observationCode, decimal value, DateTimeOffset recordedAt, CancellationToken ct = default) { if (!TrendCalculator.IsTrendCode(observationCode)) return new TrendResult(TrendOutcome.NotTrendCode); using var timer = _metrics.TrendAnalysisDuration.NewTimer(); var cache = _redis.GetDatabase(); var key = TrendCalculator.HistoryKey(encounterId, observationCode); // Append new entry to history list var entry = new TrendHistoryEntry(value, recordedAt); var historyJson = await cache.StringGetAsync(key); var history = historyJson.HasValue ? JsonSerializer.Deserialize>(historyJson!) ?? new() : new List(); history.Add(entry); // Trim to max entries and evict entries outside window var cutoff = recordedAt.AddMinutes(-_options.WindowMinutes); history = history .Where(e => e.RecordedAt >= cutoff) .TakeLast(_options.MaxHistoryEntries) .ToList(); await cache.StringSetAsync( key, JsonSerializer.Serialize(history), TimeSpan.FromSeconds(_options.HistoryTtlSeconds)); if (history.Count < 2) return new TrendResult(TrendOutcome.InsufficientHistory, observationCode); var rate = TrendCalculator.ComputeRatePerMinute(history, _options.WindowMinutes); if (rate is null) return new TrendResult(TrendOutcome.InsufficientHistory, observationCode); if (!_options.RateThresholdsPerMinute.TryGetValue(observationCode, out var threshold)) return new TrendResult(TrendOutcome.Stable, observationCode, rate); if (!TrendCalculator.ExceedsThreshold(observationCode, rate.Value, threshold)) return new TrendResult(TrendOutcome.Stable, observationCode, rate); using (var scope = _services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); if (!await db.Encounters.AnyAsync(e => e.Id == encounterId, ct)) { _logger.LogWarning( "Skipping trend alert for unknown encounter {EncounterId}", encounterId); return new TrendResult(TrendOutcome.EncounterNotFound, observationCode, rate); } } var trendContext = TrendContextBuilder.BuildContext(observationCode, history, value); var details = TrendCalculator.DescribeTrend(observationCode, rate.Value, value); var contributor = new List { new() { Parameter = TrendCalculator.DisplayName(observationCode), Points = 0, RawValue = value.ToString(), NormalRange = null } }; var created = await TryCreateAlertAsync( encounterId, patientId, observationCode, details, rate.Value, contributor, trendContext, ct); return new TrendResult( created ? TrendOutcome.RapidDeterioration : TrendOutcome.AlertAlreadyOpen, observationCode, rate, created, trendContext); } private async Task TryCreateAlertAsync( Guid encounterId, Guid patientId, string observationCode, string details, decimal ratePerMinute, List contributors, TrendContext? trendContext, CancellationToken ct) { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await using var tx = await db.Database.BeginTransactionAsync(ct); var alertId = Guid.NewGuid(); var triggeredAt = DateTimeOffset.UtcNow; var fullDetails = $"{details} — velocity {ratePerMinute:F2}/min over {_options.WindowMinutes}min window."; MedicationContext? medication = null; var correlation = scope.ServiceProvider.GetRequiredService(); medication = await correlation.TryGetContextAsync(encounterId, observationCode, ct); var explanation = AlertExplanationBuilder.Build( "Rapid deterioration", null, contributors, trendContext, medication); var inserted = await ClinicalAlertFactory.TryInsertAsync( db, alertId, encounterId, patientId, AlertType.RapidDeterioration, AlertSeverity.Warning, fullDetails, explanation, observationCode, triggeredAt, ct); if (!inserted) { await tx.RollbackAsync(ct); return false; } db.OutboxEvents.Add(new OutboxEvent { Id = Guid.NewGuid(), Topic = "alert.generated", Payload = ClinicalAlertFactory.SerializeOutboxPayload(new { alertId, encounterId, patientId, alertType = "RAPID_DETERIORATION", severity = "Warning", details = fullDetails, triggeredAt, observationCode, ratePerMinute, explanation, partitionKey = encounterId.ToString() }), PartitionKey = encounterId.ToString(), CreatedAt = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(ct); await tx.CommitAsync(ct); _metrics.TrendAlertsTotal.WithLabels(observationCode).Inc(); _metrics.ClinicalAlertsTotal .WithLabels("RAPID_DETERIORATION", "Warning").Inc(); return true; } }