diff --git a/VigilCare.ClinicalContracts/Sync/SyncedAlertEvent.cs b/VigilCare.ClinicalContracts/Sync/SyncedAlertEvent.cs index 0242168..d16665a 100644 --- a/VigilCare.ClinicalContracts/Sync/SyncedAlertEvent.cs +++ b/VigilCare.ClinicalContracts/Sync/SyncedAlertEvent.cs @@ -4,4 +4,5 @@ public record SyncedAlertEvent( string AlertType, string Severity, string Details, - DateTimeOffset GeneratedAt); \ No newline at end of file + DateTimeOffset GeneratedAt, + string? ExplanationJson = null); \ No newline at end of file diff --git a/VigilCare.Simulator/Client/Models/AlertExplanation.cs b/VigilCare.Simulator/Client/Models/AlertExplanation.cs new file mode 100644 index 0000000..23f0b24 --- /dev/null +++ b/VigilCare.Simulator/Client/Models/AlertExplanation.cs @@ -0,0 +1,32 @@ +public class AlertExplanation +{ + public List ScoreContributors { get; set; } = new(); + public TrendContext? Trend { get; set; } + public MedicationContext? MedicationContext { get; set; } + public string NarrativeSummary { get; set; } = string.Empty; +} + +public class ScoreContributor +{ + public string Parameter { get; set; } = string.Empty; + public int Points { get; set; } + public string? RawValue { get; set; } + public string? NormalRange { get; set; } +} + +public class TrendContext +{ + public string Parameter { get; set; } = string.Empty; + public double PercentChange { get; set; } + public TimeSpan Duration { get; set; } + public string Direction { get; set; } = string.Empty; +} + +public class MedicationContext +{ + public string DrugName { get; set; } = string.Empty; + public string Dose { get; set; } = string.Empty; + public string Route { get; set; } = string.Empty; + public DateTimeOffset AdministeredAt { get; set; } + public string? RelevanceNote { get; set; } +} diff --git a/VigilCare.Simulator/Client/Models/AlertResponse.cs b/VigilCare.Simulator/Client/Models/AlertResponse.cs index ab76b2e..b84d600 100644 --- a/VigilCare.Simulator/Client/Models/AlertResponse.cs +++ b/VigilCare.Simulator/Client/Models/AlertResponse.cs @@ -1,3 +1,19 @@ public record AlertResponse( - Guid Id, string AlertType, string Severity, string Status, - string Details, DateTimeOffset TriggeredAt); \ No newline at end of file + Guid Id, + Guid EncounterId, + Guid PatientId, + string AlertType, + string Severity, + string Details, + string Status, + DateTimeOffset TriggeredAt, + DateTimeOffset? AcknowledgedAt = null, + string? AcknowledgedBy = null, + DateTimeOffset? ResolvedAt = null, + AlertExplanation? Explanation = null) +{ + public string DisplaySummary => + string.IsNullOrWhiteSpace(Explanation?.NarrativeSummary) + ? Details + : Explanation!.NarrativeSummary; +} diff --git a/VigilCare.Simulator/Client/VigilCareApiClient.cs b/VigilCare.Simulator/Client/VigilCareApiClient.cs index d28ffb8..caf7bc2 100644 --- a/VigilCare.Simulator/Client/VigilCareApiClient.cs +++ b/VigilCare.Simulator/Client/VigilCareApiClient.cs @@ -1,8 +1,11 @@ using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text.Json; public class VigilCareApiClient { + private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _http; public VigilCareApiClient(HttpClient http) @@ -147,7 +150,7 @@ public class VigilCareApiClient var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/alerts"); if (!response.IsSuccessStatusCode) return new(); var envelope = await response.Content - .ReadFromJsonAsync>>(); + .ReadFromJsonAsync>>(ApiJsonOptions); return envelope?.Data?.Items?.ToList() ?? new(); } diff --git a/VigilCare.Simulator/Engine/ReplayEngine.cs b/VigilCare.Simulator/Engine/ReplayEngine.cs index bbb2363..86b0f47 100644 --- a/VigilCare.Simulator/Engine/ReplayEngine.cs +++ b/VigilCare.Simulator/Engine/ReplayEngine.cs @@ -126,6 +126,19 @@ public class ReplayEngine // --- Phase 3: Summary --- result.Duration = DateTimeOffset.UtcNow - startTime; + + if (!options.DryRun + && options.Target == ReplayTarget.Central + && scenario.ExpectedOutcomes is { Count: > 0 }) + { + var failures = await ExpectedOutcomeValidator.ValidateAsync( + _client, result.EncounterId, scenario, ct); + result.HadExpectedOutcomes = true; + result.OutcomeFailures.AddRange(failures); + foreach (var failure in failures) + SimulatorConsole.Error(failure); + } + SimulatorConsole.Summary(result); return result; } diff --git a/VigilCare.Simulator/Engine/ReplayResult.cs b/VigilCare.Simulator/Engine/ReplayResult.cs index 6fd997a..a5d1b5b 100644 --- a/VigilCare.Simulator/Engine/ReplayResult.cs +++ b/VigilCare.Simulator/Engine/ReplayResult.cs @@ -9,6 +9,10 @@ public class ReplayResult public int OrdersPlaced { get; set; } public int OrdersResulted { get; set; } public TimeSpan Duration { get; set; } + public List OutcomeFailures { get; } = new(); + public bool HadExpectedOutcomes { get; set; } + + public bool OutcomesPassed => OutcomeFailures.Count == 0; public ReplayResult(string scenarioId) => ScenarioId = scenarioId; } \ No newline at end of file diff --git a/VigilCare.Simulator/Output/SimulatorConsole.cs b/VigilCare.Simulator/Output/SimulatorConsole.cs index 46f4d23..392ebe3 100644 --- a/VigilCare.Simulator/Output/SimulatorConsole.cs +++ b/VigilCare.Simulator/Output/SimulatorConsole.cs @@ -47,6 +47,13 @@ public static class SimulatorConsole table.AddRow("Orders placed", r.OrdersPlaced.ToString()); table.AddRow("Orders resulted", r.OrdersResulted.ToString()); table.AddRow("Wall-clock time", $"{r.Duration.TotalSeconds:F1}s"); + if (r.HadExpectedOutcomes) + { + table.AddRow("Expected outcomes", + r.OutcomeFailures.Count > 0 + ? $"[red]{r.OutcomeFailures.Count} failed[/]" + : "[green]passed[/]"); + } AnsiConsole.Write(table); } @@ -88,8 +95,12 @@ public static class SimulatorConsole $"[cyan][[{simTime}]][/] qSOFA screen = {poll.Qsofa.ActiveCriteria}/3"); foreach (var alert in poll.NewAlerts) - AnsiConsole.MarkupLine( - $"[red][[{simTime}]][/] ALERT {alert.AlertType} ({alert.Severity})"); + { + var line = $"[red][[{simTime}]][/] ALERT {alert.AlertType} ({alert.Severity})"; + if (!string.IsNullOrWhiteSpace(alert.Explanation?.NarrativeSummary)) + line += $" — {Markup.Escape(alert.Explanation.NarrativeSummary)}"; + AnsiConsole.MarkupLine(line); + } if (poll.SepsisBundle is not null) AnsiConsole.MarkupLine( diff --git a/VigilCare.Simulator/Scenarios/ExpectedOutcomeValidator.cs b/VigilCare.Simulator/Scenarios/ExpectedOutcomeValidator.cs new file mode 100644 index 0000000..385f9cb --- /dev/null +++ b/VigilCare.Simulator/Scenarios/ExpectedOutcomeValidator.cs @@ -0,0 +1,150 @@ +public static class ExpectedOutcomeValidator +{ + private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8); + + public static async Task> ValidateAsync( + VigilCareApiClient client, + Guid encounterId, + ScenarioFile scenario, + CancellationToken ct = default) + { + if (scenario.ExpectedOutcomes is not { Count: > 0 }) + return []; + + await Task.Delay(AsyncSettleDelay, ct); + + var failures = new List(); + var alerts = await client.GetAlertsAsync(encounterId); + + foreach (var outcome in scenario.ExpectedOutcomes) + { + switch (outcome.Type) + { + case "alert": + failures.AddRange(ValidateAlertOutcome(outcome, alerts, scenario.Scenario.Id)); + break; + case "score": + failures.AddRange(await ValidateScoreOutcomeAsync(client, encounterId, outcome, scenario.Scenario.Id, ct)); + break; + case "bundle": + failures.AddRange(await ValidateBundleOutcomeAsync(client, encounterId, outcome, scenario.Scenario.Id, ct)); + break; + default: + failures.Add($"[{scenario.Scenario.Id}] Unknown expected outcome type '{outcome.Type}'"); + break; + } + } + + return failures; + } + + private static IEnumerable ValidateAlertOutcome( + ExpectedOutcome outcome, List alerts, string scenarioId) + { + if (string.IsNullOrWhiteSpace(outcome.AlertType)) + { + yield return $"[{scenarioId}] Alert outcome at T+{outcome.AfterOffsetMinutes}m missing alertType"; + yield break; + } + + var matching = alerts + .Where(a => string.Equals(a.AlertType, outcome.AlertType, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matching.Count == 0) + { + yield return + $"[{scenarioId}] Expected alert {outcome.AlertType} at T+{outcome.AfterOffsetMinutes}m — not found " + + $"(description: {outcome.Description ?? "n/a"})"; + yield break; + } + + if (!string.IsNullOrWhiteSpace(outcome.NarrativeContains)) + { + var withNarrative = matching.FirstOrDefault(a => + a.Explanation?.NarrativeSummary?.Contains( + outcome.NarrativeContains, StringComparison.OrdinalIgnoreCase) == true); + + if (withNarrative is null) + { + var summaries = matching + .Select(a => a.Explanation?.NarrativeSummary ?? a.Details) + .Take(2); + yield return + $"[{scenarioId}] Alert {outcome.AlertType} at T+{outcome.AfterOffsetMinutes}m — " + + $"narrative does not contain '{outcome.NarrativeContains}' " + + $"(got: {string.Join(" | ", summaries)})"; + } + } + } + + private static async Task> ValidateScoreOutcomeAsync( + VigilCareApiClient client, + Guid encounterId, + ExpectedOutcome outcome, + string scenarioId, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(outcome.ScoreType)) + return [$"[{scenarioId}] Score outcome at T+{outcome.AfterOffsetMinutes}m missing scoreType"]; + + if (!outcome.ExpectedMinimum.HasValue) + return [$"[{scenarioId}] Score outcome at T+{outcome.AfterOffsetMinutes}m missing expectedMinimum"]; + + var actual = await GetScoreAsync(client, encounterId, outcome.ScoreType, ct); + if (actual is null) + { + return + [ + $"[{scenarioId}] Expected {outcome.ScoreType} ≥ {outcome.ExpectedMinimum} at T+{outcome.AfterOffsetMinutes}m — score not available" + ]; + } + + if (actual.Value < outcome.ExpectedMinimum.Value) + { + return + [ + $"[{scenarioId}] Expected {outcome.ScoreType} ≥ {outcome.ExpectedMinimum} at T+{outcome.AfterOffsetMinutes}m — got {actual.Value} " + + $"(description: {outcome.Description ?? "n/a"})" + ]; + } + + return []; + } + + private static async Task> ValidateBundleOutcomeAsync( + VigilCareApiClient client, + Guid encounterId, + ExpectedOutcome outcome, + string scenarioId, + CancellationToken ct) + { + var bundle = await client.GetSepsisBundleAsync(encounterId); + if (bundle is null) + { + return + [ + $"[{scenarioId}] Expected sepsis bundle at T+{outcome.AfterOffsetMinutes}m — bundle not found " + + $"(description: {outcome.Description ?? "n/a"})" + ]; + } + + return []; + } + + private static async Task GetScoreAsync( + VigilCareApiClient client, + Guid encounterId, + string scoreType, + CancellationToken ct) + { + return scoreType.ToUpperInvariant() switch + { + "NEWS2" => (await client.GetCurrentNews2Async(encounterId))?.TotalScore, + "GCS" => (await client.GetCurrentGcsAsync(encounterId))?.TotalScore, + "SOFA" => (await client.GetCurrentSofaAsync(encounterId))?.TotalScore, + "QSOFA" => (await client.GetCurrentQsofaAsync(encounterId))?.ActiveCriteria, + _ => null + }; + } +} diff --git a/VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json b/VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json index ab7a7ae..3b0d8fe 100644 --- a/VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json +++ b/VigilCare.Simulator/Scenarios/List/cardiac-arrest-post-mi-01.json @@ -1269,13 +1269,15 @@ "afterOffsetMinutes": 105, "type": "alert", "alertType": "NEWS2_EMERGENCY", - "description": "NEWS2 total of 8 (>=7) triggers EMERGENCY alert \u2014 rapid hemodynamic compromise" + "narrativeContains": "NEWS2", + "description": "NEWS2 total of 8 (>=7) triggers EMERGENCY alert — rapid hemodynamic compromise" }, { "afterOffsetMinutes": 105, "type": "alert", "alertType": "RAPID_DETERIORATION", - "description": "HR rose from 88 to 121 (+33 bpm) and SBP fell from 132 to 100 (-32 mmHg) within 75 minutes, SpO2 dropped from 97 to 93 \u2014 consistent with rapid deterioration detection" + "narrativeContains": "deterioration", + "description": "HR rose from 88 to 121 (+33 bpm) and SBP fell from 132 to 100 (-32 mmHg) within 75 minutes, SpO2 dropped from 97 to 93 — consistent with rapid deterioration detection" }, { "afterOffsetMinutes": 130, diff --git a/VigilCare.Simulator/Scenarios/List/neurological-decline-gcs-01.json b/VigilCare.Simulator/Scenarios/List/neurological-decline-gcs-01.json index 8f23e85..5534856 100644 --- a/VigilCare.Simulator/Scenarios/List/neurological-decline-gcs-01.json +++ b/VigilCare.Simulator/Scenarios/List/neurological-decline-gcs-01.json @@ -156,12 +156,14 @@ "afterOffsetMinutes": 60, "type": "alert", "alertType": "GCS_WARNING", + "narrativeContains": "GCS", "description": "GCS 12 in 9–12 band" }, { "afterOffsetMinutes": 150, "type": "alert", "alertType": "GCS_CRITICAL", + "narrativeContains": "GCS", "description": "GCS 6 ≤ 8" } ] diff --git a/VigilCare.Simulator/Scenarios/List/sepsis-sofa-progression-01.json b/VigilCare.Simulator/Scenarios/List/sepsis-sofa-progression-01.json index 97f98c3..9ace868 100644 --- a/VigilCare.Simulator/Scenarios/List/sepsis-sofa-progression-01.json +++ b/VigilCare.Simulator/Scenarios/List/sepsis-sofa-progression-01.json @@ -252,6 +252,7 @@ "afterOffsetMinutes": 130, "type": "alert", "alertType": "SOFA_SEPSIS", + "narrativeContains": "SOFA", "description": "SOFA delta ≥ 2 from baseline after labs + vasopressor" }, { diff --git a/VigilCare.Simulator/Scenarios/ScenarioFile.cs b/VigilCare.Simulator/Scenarios/ScenarioFile.cs index 5967e3f..7fecb9c 100644 --- a/VigilCare.Simulator/Scenarios/ScenarioFile.cs +++ b/VigilCare.Simulator/Scenarios/ScenarioFile.cs @@ -25,4 +25,5 @@ public record ScenarioEvent( public record ExpectedOutcome( double AfterOffsetMinutes, string Type, string? AlertType, string? ScoreType, - double? ExpectedMinimum, string? Description); \ No newline at end of file + double? ExpectedMinimum, string? Description, + string? NarrativeContains = null); \ No newline at end of file diff --git a/VigilCare.Simulator/Scenarios/schema.json b/VigilCare.Simulator/Scenarios/schema.json index acd1bc5..ca79a9d 100644 --- a/VigilCare.Simulator/Scenarios/schema.json +++ b/VigilCare.Simulator/Scenarios/schema.json @@ -60,7 +60,8 @@ "alertType": { "type": "string" }, "scoreType": { "type": "string" }, "expectedMinimum": { "type": "number" }, - "description": { "type": "string" } + "description": { "type": "string" }, + "narrativeContains": { "type": "string", "description": "Substring expected in explanation.narrativeSummary for explainable scoring alerts" } } } }, diff --git a/VigilCare.WardGateway/BackgroundService/SyncUploaderService.cs b/VigilCare.WardGateway/BackgroundService/SyncUploaderService.cs index 4fb9eb3..ff22d70 100644 --- a/VigilCare.WardGateway/BackgroundService/SyncUploaderService.cs +++ b/VigilCare.WardGateway/BackgroundService/SyncUploaderService.cs @@ -170,7 +170,8 @@ public sealed class SyncUploaderService : BackgroundService p.AlertType, p.Severity, p.Details, - p.GeneratedAt); + p.GeneratedAt, + p.ExplanationJson); } private static SyncedAlertAcknowledgment MapAck(BufferedSyncItem item) @@ -215,7 +216,8 @@ public sealed class SyncUploaderService : BackgroundService string AlertType, string Severity, string Details, - DateTimeOffset GeneratedAt); + DateTimeOffset GeneratedAt, + string? ExplanationJson = null); private sealed record BufferedAckPayload( Guid ClientRef, diff --git a/VigilCare.WardGateway/Data/Configurations/LocalClinicalAlertConfiguration.cs b/VigilCare.WardGateway/Data/Configurations/LocalClinicalAlertConfiguration.cs index abc5093..1dca7ca 100644 --- a/VigilCare.WardGateway/Data/Configurations/LocalClinicalAlertConfiguration.cs +++ b/VigilCare.WardGateway/Data/Configurations/LocalClinicalAlertConfiguration.cs @@ -65,6 +65,9 @@ public class LocalClinicalAlertConfiguration : IEntityTypeConfiguration a.ResolvedAt).HasColumnName("resolved_at"); builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id").IsRequired(); builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()"); + builder.Property(a => a.ExplanationJson) + .HasColumnName("explanation") + .HasColumnType("jsonb"); builder.HasOne(a => a.Encounter) .WithMany(e => e.Alerts) diff --git a/VigilCare.WardGateway/Domain/Entities/LocalClinicalAlert.cs b/VigilCare.WardGateway/Domain/Entities/LocalClinicalAlert.cs index 3cb56e9..a76eb18 100644 --- a/VigilCare.WardGateway/Domain/Entities/LocalClinicalAlert.cs +++ b/VigilCare.WardGateway/Domain/Entities/LocalClinicalAlert.cs @@ -14,6 +14,7 @@ public class LocalClinicalAlert public DateTimeOffset? ResolvedAt { get; set; } public DateTimeOffset TriggeredAt { get; set; } public Guid ClientAlertId { get; set; } + public string? ExplanationJson { get; set; } public ReplicaEncounter Encounter { get; set; } = null!; } diff --git a/VigilCare.WardGateway/Migrations/20260625000000_AddLocalAlertExplanation.cs b/VigilCare.WardGateway/Migrations/20260625000000_AddLocalAlertExplanation.cs new file mode 100644 index 0000000..8e4a480 --- /dev/null +++ b/VigilCare.WardGateway/Migrations/20260625000000_AddLocalAlertExplanation.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VigilCare.WardGateway.Migrations; + +public partial class AddLocalAlertExplanation : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "explanation", + table: "clinical_alerts", + type: "jsonb", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "explanation", + table: "clinical_alerts"); + } +} diff --git a/VigilCare.WardGateway/Migrations/GatewayDbContextModelSnapshot.cs b/VigilCare.WardGateway/Migrations/GatewayDbContextModelSnapshot.cs index 0093ca3..88827b3 100644 --- a/VigilCare.WardGateway/Migrations/GatewayDbContextModelSnapshot.cs +++ b/VigilCare.WardGateway/Migrations/GatewayDbContextModelSnapshot.cs @@ -140,6 +140,10 @@ namespace VigilCare.WardGateway.Migrations .HasColumnType("text") .HasColumnName("details"); + b.Property("ExplanationJson") + .HasColumnType("jsonb") + .HasColumnName("explanation"); + b.Property("EncounterId") .HasColumnType("uuid") .HasColumnName("encounter_id"); diff --git a/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs b/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs index 62b559a..ecfca4b 100644 --- a/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs +++ b/VigilCareClinicalAPI/BackgroundServices/ElasticsSearch/EsIndexerService.cs @@ -227,6 +227,13 @@ public class EsIndexerService : BackgroundService AlertType = evt.AlertType, Severity = evt.Severity, Status = "Open", + Details = root.TryGetProperty("details", out var detailsElem) + ? detailsElem.GetString() ?? string.Empty + : string.Empty, + NarrativeSummary = root.TryGetProperty("explanation", out var explanationElem) + && explanationElem.TryGetProperty("narrativeSummary", out var narrativeElem) + ? narrativeElem.GetString() + : null, TriggeredAt = evt.TriggeredAt }; diff --git a/VigilCareClinicalAPI/Controllers/AlertsController.cs b/VigilCareClinicalAPI/Controllers/AlertsController.cs index 8a44c6d..93aa974 100644 --- a/VigilCareClinicalAPI/Controllers/AlertsController.cs +++ b/VigilCareClinicalAPI/Controllers/AlertsController.cs @@ -135,13 +135,13 @@ public class AlertsController : ControllerBase /// The updated alert. [HttpPost("api/v1/alerts/{id:guid}/acknowledge")] [AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] public async Task Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req) { var alert = await _alerts.AcknowledgeAsync(id, req); - return Ok(ApiResponse.Ok(alert)); + return Ok(ApiResponse.Ok(alert)); } /// @@ -151,13 +151,13 @@ public class AlertsController : ControllerBase /// The updated alert. [HttpPost("api/v1/alerts/{id:guid}/resolve")] [AuthorizePermission(ClinicalPermissions.AlertsResolve)] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] public async Task Resolve(Guid id) { var alert = await _alerts.ResolveAsync(id); - return Ok(ApiResponse.Ok(alert)); + return Ok(ApiResponse.Ok(alert)); } /// diff --git a/VigilCareClinicalAPI/DataLake/DataLakeEventParser.cs b/VigilCareClinicalAPI/DataLake/DataLakeEventParser.cs index e72bd6c..defd497 100644 --- a/VigilCareClinicalAPI/DataLake/DataLakeEventParser.cs +++ b/VigilCareClinicalAPI/DataLake/DataLakeEventParser.cs @@ -39,6 +39,7 @@ public static class DataLakeEventParser AlertType : GetString(d, "alertType"), Severity : GetString(d, "severity"), Details : GetString(d, "details"), + ExplanationJson: GetJsonObjectString(d, "explanation"), TriggeredAt : GetTimestampString(d, "triggeredAt"), KafkaPartition : partition, KafkaOffset : offset); @@ -78,6 +79,13 @@ public static class DataLakeEventParser } } + private static string GetJsonObjectString(JsonElement d, string name) + { + if (!TryGetProperty(d, name, out var prop) || prop.ValueKind != JsonValueKind.Object) + return ""; + return prop.GetRawText(); + } + private static string GetString(JsonElement d, string primary, string? alternate = null) { if (TryGetProperty(d, primary, out var prop)) diff --git a/VigilCareClinicalAPI/DataLake/ParquetFileBuilder.cs b/VigilCareClinicalAPI/DataLake/ParquetFileBuilder.cs index 54aeb33..e427d6b 100644 --- a/VigilCareClinicalAPI/DataLake/ParquetFileBuilder.cs +++ b/VigilCareClinicalAPI/DataLake/ParquetFileBuilder.cs @@ -49,6 +49,7 @@ public static class ParquetFileBuilder new DataField("alert_type"), new DataField("severity"), new DataField("details"), + new DataField("explanation_json"), new DataField("triggered_at"), new DataField("kafka_partition"), new DataField("kafka_offset") @@ -65,9 +66,10 @@ public static class ParquetFileBuilder await rg.WriteColumnAsync(new DataColumn(f[3], rows.Select(r => r.AlertType).ToArray())); await rg.WriteColumnAsync(new DataColumn(f[4], rows.Select(r => r.Severity).ToArray())); await rg.WriteColumnAsync(new DataColumn(f[5], rows.Select(r => r.Details).ToArray())); - await rg.WriteColumnAsync(new DataColumn(f[6], rows.Select(r => r.TriggeredAt).ToArray())); - await rg.WriteColumnAsync(new DataColumn(f[7], rows.Select(r => r.KafkaPartition).ToArray())); - await rg.WriteColumnAsync(new DataColumn(f[8], rows.Select(r => r.KafkaOffset).ToArray())); + await rg.WriteColumnAsync(new DataColumn(f[6], rows.Select(r => r.ExplanationJson).ToArray())); + await rg.WriteColumnAsync(new DataColumn(f[7], rows.Select(r => r.TriggeredAt).ToArray())); + await rg.WriteColumnAsync(new DataColumn(f[8], rows.Select(r => r.KafkaPartition).ToArray())); + await rg.WriteColumnAsync(new DataColumn(f[9], rows.Select(r => r.KafkaOffset).ToArray())); } return ms.ToArray(); } diff --git a/VigilCareClinicalAPI/Elasticsearch/Documents/ClinicalAlertDocument.cs b/VigilCareClinicalAPI/Elasticsearch/Documents/ClinicalAlertDocument.cs index 428fc6d..86b950a 100644 --- a/VigilCareClinicalAPI/Elasticsearch/Documents/ClinicalAlertDocument.cs +++ b/VigilCareClinicalAPI/Elasticsearch/Documents/ClinicalAlertDocument.cs @@ -7,5 +7,7 @@ public class ClinicalAlertDocument public string AlertType { get; set; } = null!; public string Severity { get; set; } = null!; public string Status { get; set; } = null!; + public string Details { get; set; } = string.Empty; + public string? NarrativeSummary { get; set; } public DateTimeOffset TriggeredAt { get; set; } } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Models/Records/Alert/AlertRow.cs b/VigilCareClinicalAPI/Models/Records/Alert/AlertRow.cs index 7e04659..c8162d1 100644 --- a/VigilCareClinicalAPI/Models/Records/Alert/AlertRow.cs +++ b/VigilCareClinicalAPI/Models/Records/Alert/AlertRow.cs @@ -5,6 +5,7 @@ public sealed record AlertRow( string AlertType, string Severity, string Details, + string ExplanationJson, string TriggeredAt, int KafkaPartition, long KafkaOffset diff --git a/VigilCareClinicalAPI/Services/AlertService.cs b/VigilCareClinicalAPI/Services/AlertService.cs index 8c8be0e..14bc01c 100644 --- a/VigilCareClinicalAPI/Services/AlertService.cs +++ b/VigilCareClinicalAPI/Services/AlertService.cs @@ -85,7 +85,7 @@ public class AlertService : IAlertService return AlertResponseMapper.ToResponse(alert); } - public async Task AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req) + public async Task AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req) { if (!_currentUser.IsAuthenticated) throw new ValidationException("Authentication required.", "AUTH_REQUIRED"); @@ -164,7 +164,7 @@ public class AlertService : IAlertService reason: acknowledgmentNote); } - return alert; + return AlertResponseMapper.ToResponse(alert); } public async Task SubmitFeedbackAsync( @@ -234,7 +234,7 @@ public class AlertService : IAlertService return overrideMinutes ?? defaultWindowMinutes; } - public async Task ResolveAsync(Guid id) + public async Task ResolveAsync(Guid id) { var alert = await _db.ClinicalAlerts.FindAsync(id); if (alert is null) @@ -256,7 +256,7 @@ public class AlertService : IAlertService previousValue: new { status = AlertStatus.Acknowledged.ToDbString() }, newValue: new { status = alert.Status.ToDbString() }); - return alert; + return AlertResponseMapper.ToResponse(alert); } public async Task ApplySyncedAcknowledgmentAsync( diff --git a/VigilCareClinicalAPI/Services/ClinicalSyncBatchProcessor.cs b/VigilCareClinicalAPI/Services/ClinicalSyncBatchProcessor.cs index 1921603..6ab65ea 100644 --- a/VigilCareClinicalAPI/Services/ClinicalSyncBatchProcessor.cs +++ b/VigilCareClinicalAPI/Services/ClinicalSyncBatchProcessor.cs @@ -6,6 +6,11 @@ using VigilCare.ClinicalContracts.Sync; public class ClinicalSyncBatchProcessor { + private static readonly JsonSerializerOptions ExplanationJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + private readonly AppDbContext _db; private readonly IObservationService _observations; private readonly IAlertService _alerts; @@ -163,6 +168,7 @@ public class ClinicalSyncBatchProcessor AlertType = AlertTypeExtensions.FromDbString(alert.AlertType), Severity = AlertSeverityExtensions.FromDbString(alert.Severity), Details = alert.Details, + Explanation = ParseExplanation(alert.ExplanationJson), Status = AlertStatus.Open, TriggeredAt = alert.GeneratedAt }; @@ -172,7 +178,7 @@ public class ClinicalSyncBatchProcessor { Id = Guid.NewGuid(), Topic = "alert.generated", - Payload = JsonSerializer.Serialize(new + Payload = ClinicalAlertFactory.SerializeOutboxPayload(new { alertId = clinicalAlert.Id, encounterId = alert.EncounterId, @@ -180,6 +186,7 @@ public class ClinicalSyncBatchProcessor alertType = alert.AlertType, severity = alert.Severity, details = alert.Details, + explanation = clinicalAlert.Explanation, syncedFromGateway = true, triggeredAt = alert.GeneratedAt, partitionKey = alert.EncounterId.ToString() @@ -231,4 +238,9 @@ public class ClinicalSyncBatchProcessor _db.ClinicalSyncConflicts.Add(new ClinicalSyncConflict(batchId, clientRef, itemType, reason)); await _db.SaveChangesAsync(ct); } + + private static AlertExplanation? ParseExplanation(string? explanationJson) => + string.IsNullOrWhiteSpace(explanationJson) + ? null + : JsonSerializer.Deserialize(explanationJson, ExplanationJsonOptions); } \ No newline at end of file diff --git a/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs b/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs index 2f5cb8d..08c498a 100644 --- a/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs +++ b/VigilCareClinicalAPI/Services/Interfaces/IAlertService.cs @@ -8,9 +8,9 @@ public interface IAlertService Task GetByIdAsync(Guid id); - Task AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req); + Task AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req); - Task ResolveAsync(Guid id); + Task ResolveAsync(Guid id); Task ApplySyncedAcknowledgmentAsync(Guid alertId, SyncedAlertAcknowledgment ack, CancellationToken ct); Task ApplySyncedResolutionAsync(Guid alertId, SyncedAlertResolution resolve, CancellationToken ct); Task SubmitFeedbackAsync(Guid alertId, AlertFeedbackType type, string? comment); diff --git a/docs/alert-optimization-roadmap.md b/docs/alert-optimization-roadmap.md index c158055..ad838b1 100644 --- a/docs/alert-optimization-roadmap.md +++ b/docs/alert-optimization-roadmap.md @@ -24,16 +24,16 @@ Each priority is scored on three dimensions using a 1-10 scale. ## Summary Matrix -| Phase | Feature | Commercial | Clinical | Composite | Dependencies | -|-------|------------------------------------|------------|----------|-----------|--------------------| -| 33 | Alert Quality Analytics | 10 | 9 | 9.5 | None | -| 34 | Explainable Alerts | 8 | 10 | 9.0 | None | -| 35 | Alert Lifecycle Analytics | 9 | 7 | 8.0 | Phase 33 | -| 36 | Role-Based Alert Routing | 8 | 9 | 8.5 | Phase 33, 35 | -| 37 | Alert Bundling and Correlation | 7 | 8 | 7.5 | Phase 34 | -| 38 | Adaptive Threshold Recommendations | 9 | 6 | 7.5 | Phase 33, 35 (months of data) | -| 39 | Scoring Framework Abstraction | 5 | 4 | 4.5 | None | -| 40 | MEWS | 5 | 5 | 5.0 | Phase 39 | +| Phase | Feature | Commercial | Clinical | Composite | Dependencies | Status | +|-------|------------------------------------|------------|----------|-----------|--------------------|-----------| +| 33 | Alert Quality Analytics | 10 | 9 | 9.5 | None | — | +| 34 | Explainable Alerts | 8 | 10 | 9.0 | None | Complete | +| 35 | Alert Lifecycle Analytics | 9 | 7 | 8.0 | Phase 33 | — | +| 36 | Role-Based Alert Routing | 8 | 9 | 8.5 | Phase 33, 35 | — | +| 37 | Alert Bundling and Correlation | 7 | 8 | 7.5 | Phase 34 | — | +| 38 | Adaptive Threshold Recommendations | 9 | 6 | 7.5 | Phase 33, 35 (months of data) | — | +| 39 | Scoring Framework Abstraction | 5 | 4 | 4.5 | None | — | +| 40 | MEWS | 5 | 5 | 5.0 | Phase 39 | — | --- diff --git a/docs/decisions/data-lake-design.md b/docs/decisions/data-lake-design.md index 9417e75..5d1f7ec 100644 --- a/docs/decisions/data-lake-design.md +++ b/docs/decisions/data-lake-design.md @@ -90,6 +90,7 @@ offsets are not committed and events are re-read on the next start. | `alert_type` | `alertType` | e.g. `WARNING_HEART_RATE`, `CRITICAL_HEART_RATE` | | `severity` | `severity` | e.g. `Warning`, `Critical` | | `details` | `details` | Required on all new alert producers; parser defaults to `""` if absent | +| `explanation_json` | `explanation` | Optional JSON object (Phase 34); null for threshold-only alerts | | `triggered_at` | `triggeredAt` | ISO-8601 | Phase 11 aligned warning and sepsis outbox producers with critical ingest alerts by diff --git a/docs/patient-encounter-api-lifecycle.md b/docs/patient-encounter-api-lifecycle.md index a218b1c..4c31888 100644 --- a/docs/patient-encounter-api-lifecycle.md +++ b/docs/patient-encounter-api-lifecycle.md @@ -980,7 +980,7 @@ The Vue ward dashboard (`vigilcare-dashboard/`) implements this lifecycle throug | Patient Detail | `GET /encounters/{id}`, `GET .../news2/current`, `GET .../sofa/current`, `GET .../medications`, `GET .../orders`, `GET .../sepsis-bundle/current`, `GET .../observations`, `GET .../news2/history` | | Alert Center | `GET /alerts?status=OPEN` | | Alert actions | `POST /alerts/{id}/acknowledge`, `POST /alerts/{id}/resolve` | -| Alert reasoning | `alert.details` from alert list + client-side medication window from `GET .../medications` | +| Alert reasoning | `alert.explanation` from alert list/GET (narrative, score contributors, trend, medication context); legacy alerts fall back to `alert.details` | --- @@ -996,7 +996,7 @@ The Vue ward dashboard (`vigilcare-dashboard/`) implements this lifecycle throug | qSOFA screen | No | PostgreSQL → Kafka → sepsis engine (reads Redis) → PostgreSQL | `GET .../alerts`, `GET .../qsofa/current` | | GCS score / alert | No | PostgreSQL → Kafka → GCS scorer → PostgreSQL | `GET .../gcs/current`, `GET .../alerts` | | Trend alert | No | PostgreSQL → Kafka → trend analyzer (reads Redis history) → PostgreSQL | `GET .../alerts` | -| Medication annotation on alert | No — applied at alert creation | Kafka consumer reads recent meds from PostgreSQL | `GET .../alerts` → read `details` field | +| Medication annotation on alert | No — applied at alert creation | Kafka consumer reads recent meds from PostgreSQL | `GET .../alerts` → read `explanation.medicationContext` or legacy `details` | | Search index updated | No | PostgreSQL → Kafka → ES indexer → Elasticsearch | `GET /analytics/...` endpoints | | Data lake file written | No | PostgreSQL → Kafka → data lake writer → MinIO (Parquet); offsets only committed for successful partition uploads | MinIO bucket `vigilcare` | | Clinician paged | No | Kafka → notification publisher → RabbitMQ paging queue | Alert `status` field | diff --git a/vigilcare-dashboard/src/api/alerts.js b/vigilcare-dashboard/src/api/alerts.js index bf7b710..4006912 100644 --- a/vigilcare-dashboard/src/api/alerts.js +++ b/vigilcare-dashboard/src/api/alerts.js @@ -1,5 +1,9 @@ import { api } from './client' +export function fetchAlertById(alertId) { + return api.get(`/api/v1/alerts/${alertId}`) +} + export function fetchAlerts(encounterId, status) { const params = new URLSearchParams() if (status) params.set('status', status) diff --git a/vigilcare-dashboard/src/views/PatientDetail.vue b/vigilcare-dashboard/src/views/PatientDetail.vue index 386f0b3..e69dc24 100644 --- a/vigilcare-dashboard/src/views/PatientDetail.vue +++ b/vigilcare-dashboard/src/views/PatientDetail.vue @@ -9,6 +9,7 @@ import { useAlertStore } from '@/stores/alerts' import { useScoringStore } from '@/stores/scoring' import * as encountersApi from '@/api/encounters' import * as clinicalApi from '@/api/clinical' +import * as alertsApi from '@/api/alerts' import PatientBanner from '@/components/patient/PatientBanner.vue' import VitalsPanel from '@/components/patient/VitalsPanel.vue' import ScoresPanel from '@/components/patient/ScoresPanel.vue' @@ -157,8 +158,12 @@ async function loadAll() { } } -function onSelectAlert(alert) { - selectedAlert.value = alert +async function onSelectAlert(alert) { + try { + selectedAlert.value = await alertsApi.fetchAlertById(alert.id) + } catch { + selectedAlert.value = alert + } jumpToTimestamp(alert.triggeredAt) }