chore: necessary updates given the changes in the alert controller
This commit is contained in:
@@ -4,4 +4,5 @@ public record SyncedAlertEvent(
|
|||||||
string AlertType,
|
string AlertType,
|
||||||
string Severity,
|
string Severity,
|
||||||
string Details,
|
string Details,
|
||||||
DateTimeOffset GeneratedAt);
|
DateTimeOffset GeneratedAt,
|
||||||
|
string? ExplanationJson = null);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
public class AlertExplanation
|
||||||
|
{
|
||||||
|
public List<ScoreContributor> 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; }
|
||||||
|
}
|
||||||
@@ -1,3 +1,19 @@
|
|||||||
public record AlertResponse(
|
public record AlertResponse(
|
||||||
Guid Id, string AlertType, string Severity, string Status,
|
Guid Id,
|
||||||
string Details, DateTimeOffset TriggeredAt);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
public class VigilCareApiClient
|
public class VigilCareApiClient
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
public VigilCareApiClient(HttpClient http)
|
public VigilCareApiClient(HttpClient http)
|
||||||
@@ -147,7 +150,7 @@ public class VigilCareApiClient
|
|||||||
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/alerts");
|
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/alerts");
|
||||||
if (!response.IsSuccessStatusCode) return new();
|
if (!response.IsSuccessStatusCode) return new();
|
||||||
var envelope = await response.Content
|
var envelope = await response.Content
|
||||||
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>();
|
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>(ApiJsonOptions);
|
||||||
return envelope?.Data?.Items?.ToList() ?? new();
|
return envelope?.Data?.Items?.ToList() ?? new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,19 @@ public class ReplayEngine
|
|||||||
|
|
||||||
// --- Phase 3: Summary ---
|
// --- Phase 3: Summary ---
|
||||||
result.Duration = DateTimeOffset.UtcNow - startTime;
|
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);
|
SimulatorConsole.Summary(result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public class ReplayResult
|
|||||||
public int OrdersPlaced { get; set; }
|
public int OrdersPlaced { get; set; }
|
||||||
public int OrdersResulted { get; set; }
|
public int OrdersResulted { get; set; }
|
||||||
public TimeSpan Duration { get; set; }
|
public TimeSpan Duration { get; set; }
|
||||||
|
public List<string> OutcomeFailures { get; } = new();
|
||||||
|
public bool HadExpectedOutcomes { get; set; }
|
||||||
|
|
||||||
|
public bool OutcomesPassed => OutcomeFailures.Count == 0;
|
||||||
|
|
||||||
public ReplayResult(string scenarioId) => ScenarioId = scenarioId;
|
public ReplayResult(string scenarioId) => ScenarioId = scenarioId;
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,13 @@ public static class SimulatorConsole
|
|||||||
table.AddRow("Orders placed", r.OrdersPlaced.ToString());
|
table.AddRow("Orders placed", r.OrdersPlaced.ToString());
|
||||||
table.AddRow("Orders resulted", r.OrdersResulted.ToString());
|
table.AddRow("Orders resulted", r.OrdersResulted.ToString());
|
||||||
table.AddRow("Wall-clock time", $"{r.Duration.TotalSeconds:F1}s");
|
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);
|
AnsiConsole.Write(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +95,12 @@ public static class SimulatorConsole
|
|||||||
$"[cyan][[{simTime}]][/] qSOFA screen = {poll.Qsofa.ActiveCriteria}/3");
|
$"[cyan][[{simTime}]][/] qSOFA screen = {poll.Qsofa.ActiveCriteria}/3");
|
||||||
|
|
||||||
foreach (var alert in poll.NewAlerts)
|
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)
|
if (poll.SepsisBundle is not null)
|
||||||
AnsiConsole.MarkupLine(
|
AnsiConsole.MarkupLine(
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
public static class ExpectedOutcomeValidator
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan AsyncSettleDelay = TimeSpan.FromSeconds(8);
|
||||||
|
|
||||||
|
public static async Task<List<string>> 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<string>();
|
||||||
|
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<string> ValidateAlertOutcome(
|
||||||
|
ExpectedOutcome outcome, List<AlertResponse> 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<IEnumerable<string>> 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<IEnumerable<string>> 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<int?> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1269,13 +1269,15 @@
|
|||||||
"afterOffsetMinutes": 105,
|
"afterOffsetMinutes": 105,
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"alertType": "NEWS2_EMERGENCY",
|
"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,
|
"afterOffsetMinutes": 105,
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"alertType": "RAPID_DETERIORATION",
|
"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,
|
"afterOffsetMinutes": 130,
|
||||||
|
|||||||
@@ -156,12 +156,14 @@
|
|||||||
"afterOffsetMinutes": 60,
|
"afterOffsetMinutes": 60,
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"alertType": "GCS_WARNING",
|
"alertType": "GCS_WARNING",
|
||||||
|
"narrativeContains": "GCS",
|
||||||
"description": "GCS 12 in 9–12 band"
|
"description": "GCS 12 in 9–12 band"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"afterOffsetMinutes": 150,
|
"afterOffsetMinutes": 150,
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"alertType": "GCS_CRITICAL",
|
"alertType": "GCS_CRITICAL",
|
||||||
|
"narrativeContains": "GCS",
|
||||||
"description": "GCS 6 ≤ 8"
|
"description": "GCS 6 ≤ 8"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -252,6 +252,7 @@
|
|||||||
"afterOffsetMinutes": 130,
|
"afterOffsetMinutes": 130,
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"alertType": "SOFA_SEPSIS",
|
"alertType": "SOFA_SEPSIS",
|
||||||
|
"narrativeContains": "SOFA",
|
||||||
"description": "SOFA delta ≥ 2 from baseline after labs + vasopressor"
|
"description": "SOFA delta ≥ 2 from baseline after labs + vasopressor"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ public record ScenarioEvent(
|
|||||||
public record ExpectedOutcome(
|
public record ExpectedOutcome(
|
||||||
double AfterOffsetMinutes, string Type,
|
double AfterOffsetMinutes, string Type,
|
||||||
string? AlertType, string? ScoreType,
|
string? AlertType, string? ScoreType,
|
||||||
double? ExpectedMinimum, string? Description);
|
double? ExpectedMinimum, string? Description,
|
||||||
|
string? NarrativeContains = null);
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
"alertType": { "type": "string" },
|
"alertType": { "type": "string" },
|
||||||
"scoreType": { "type": "string" },
|
"scoreType": { "type": "string" },
|
||||||
"expectedMinimum": { "type": "number" },
|
"expectedMinimum": { "type": "number" },
|
||||||
"description": { "type": "string" }
|
"description": { "type": "string" },
|
||||||
|
"narrativeContains": { "type": "string", "description": "Substring expected in explanation.narrativeSummary for explainable scoring alerts" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ public sealed class SyncUploaderService : BackgroundService
|
|||||||
p.AlertType,
|
p.AlertType,
|
||||||
p.Severity,
|
p.Severity,
|
||||||
p.Details,
|
p.Details,
|
||||||
p.GeneratedAt);
|
p.GeneratedAt,
|
||||||
|
p.ExplanationJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SyncedAlertAcknowledgment MapAck(BufferedSyncItem item)
|
private static SyncedAlertAcknowledgment MapAck(BufferedSyncItem item)
|
||||||
@@ -215,7 +216,8 @@ public sealed class SyncUploaderService : BackgroundService
|
|||||||
string AlertType,
|
string AlertType,
|
||||||
string Severity,
|
string Severity,
|
||||||
string Details,
|
string Details,
|
||||||
DateTimeOffset GeneratedAt);
|
DateTimeOffset GeneratedAt,
|
||||||
|
string? ExplanationJson = null);
|
||||||
|
|
||||||
private sealed record BufferedAckPayload(
|
private sealed record BufferedAckPayload(
|
||||||
Guid ClientRef,
|
Guid ClientRef,
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ public class LocalClinicalAlertConfiguration : IEntityTypeConfiguration<LocalCli
|
|||||||
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
|
builder.Property(a => a.ResolvedAt).HasColumnName("resolved_at");
|
||||||
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id").IsRequired();
|
builder.Property(a => a.ClientAlertId).HasColumnName("client_alert_id").IsRequired();
|
||||||
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
builder.Property(a => a.TriggeredAt).HasColumnName("triggered_at").HasDefaultValueSql("NOW()");
|
||||||
|
builder.Property(a => a.ExplanationJson)
|
||||||
|
.HasColumnName("explanation")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
builder.HasOne(a => a.Encounter)
|
builder.HasOne(a => a.Encounter)
|
||||||
.WithMany(e => e.Alerts)
|
.WithMany(e => e.Alerts)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class LocalClinicalAlert
|
|||||||
public DateTimeOffset? ResolvedAt { get; set; }
|
public DateTimeOffset? ResolvedAt { get; set; }
|
||||||
public DateTimeOffset TriggeredAt { get; set; }
|
public DateTimeOffset TriggeredAt { get; set; }
|
||||||
public Guid ClientAlertId { get; set; }
|
public Guid ClientAlertId { get; set; }
|
||||||
|
public string? ExplanationJson { get; set; }
|
||||||
|
|
||||||
public ReplicaEncounter Encounter { get; set; } = null!;
|
public ReplicaEncounter Encounter { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string>(
|
||||||
|
name: "explanation",
|
||||||
|
table: "clinical_alerts",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "explanation",
|
||||||
|
table: "clinical_alerts");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,10 @@ namespace VigilCare.WardGateway.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("details");
|
.HasColumnName("details");
|
||||||
|
|
||||||
|
b.Property<string>("ExplanationJson")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("explanation");
|
||||||
|
|
||||||
b.Property<Guid>("EncounterId")
|
b.Property<Guid>("EncounterId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("encounter_id");
|
.HasColumnName("encounter_id");
|
||||||
|
|||||||
@@ -227,6 +227,13 @@ public class EsIndexerService : BackgroundService
|
|||||||
AlertType = evt.AlertType,
|
AlertType = evt.AlertType,
|
||||||
Severity = evt.Severity,
|
Severity = evt.Severity,
|
||||||
Status = "Open",
|
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
|
TriggeredAt = evt.TriggeredAt
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -135,13 +135,13 @@ public class AlertsController : ControllerBase
|
|||||||
/// <returns>The updated alert.</returns>
|
/// <returns>The updated alert.</returns>
|
||||||
[HttpPost("api/v1/alerts/{id:guid}/acknowledge")]
|
[HttpPost("api/v1/alerts/{id:guid}/acknowledge")]
|
||||||
[AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)]
|
[AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<AlertResponse>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
public async Task<IActionResult> Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req)
|
public async Task<IActionResult> Acknowledge(Guid id, [FromBody] AcknowledgeAlertRequest req)
|
||||||
{
|
{
|
||||||
var alert = await _alerts.AcknowledgeAsync(id, req);
|
var alert = await _alerts.AcknowledgeAsync(id, req);
|
||||||
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
|
return Ok(ApiResponse<AlertResponse>.Ok(alert));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -151,13 +151,13 @@ public class AlertsController : ControllerBase
|
|||||||
/// <returns>The updated alert.</returns>
|
/// <returns>The updated alert.</returns>
|
||||||
[HttpPost("api/v1/alerts/{id:guid}/resolve")]
|
[HttpPost("api/v1/alerts/{id:guid}/resolve")]
|
||||||
[AuthorizePermission(ClinicalPermissions.AlertsResolve)]
|
[AuthorizePermission(ClinicalPermissions.AlertsResolve)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<ClinicalAlert>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<AlertResponse>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||||
public async Task<IActionResult> Resolve(Guid id)
|
public async Task<IActionResult> Resolve(Guid id)
|
||||||
{
|
{
|
||||||
var alert = await _alerts.ResolveAsync(id);
|
var alert = await _alerts.ResolveAsync(id);
|
||||||
return Ok(ApiResponse<ClinicalAlert>.Ok(alert));
|
return Ok(ApiResponse<AlertResponse>.Ok(alert));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public static class DataLakeEventParser
|
|||||||
AlertType : GetString(d, "alertType"),
|
AlertType : GetString(d, "alertType"),
|
||||||
Severity : GetString(d, "severity"),
|
Severity : GetString(d, "severity"),
|
||||||
Details : GetString(d, "details"),
|
Details : GetString(d, "details"),
|
||||||
|
ExplanationJson: GetJsonObjectString(d, "explanation"),
|
||||||
TriggeredAt : GetTimestampString(d, "triggeredAt"),
|
TriggeredAt : GetTimestampString(d, "triggeredAt"),
|
||||||
KafkaPartition : partition,
|
KafkaPartition : partition,
|
||||||
KafkaOffset : offset);
|
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)
|
private static string GetString(JsonElement d, string primary, string? alternate = null)
|
||||||
{
|
{
|
||||||
if (TryGetProperty(d, primary, out var prop))
|
if (TryGetProperty(d, primary, out var prop))
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public static class ParquetFileBuilder
|
|||||||
new DataField<string>("alert_type"),
|
new DataField<string>("alert_type"),
|
||||||
new DataField<string>("severity"),
|
new DataField<string>("severity"),
|
||||||
new DataField<string>("details"),
|
new DataField<string>("details"),
|
||||||
|
new DataField<string>("explanation_json"),
|
||||||
new DataField<string>("triggered_at"),
|
new DataField<string>("triggered_at"),
|
||||||
new DataField<int>("kafka_partition"),
|
new DataField<int>("kafka_partition"),
|
||||||
new DataField<long>("kafka_offset")
|
new DataField<long>("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[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[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[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[6], rows.Select(r => r.ExplanationJson).ToArray()));
|
||||||
await rg.WriteColumnAsync(new DataColumn(f[7], rows.Select(r => r.KafkaPartition).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.KafkaOffset).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();
|
return ms.ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ public class ClinicalAlertDocument
|
|||||||
public string AlertType { get; set; } = null!;
|
public string AlertType { get; set; } = null!;
|
||||||
public string Severity { get; set; } = null!;
|
public string Severity { get; set; } = null!;
|
||||||
public string Status { 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; }
|
public DateTimeOffset TriggeredAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ public sealed record AlertRow(
|
|||||||
string AlertType,
|
string AlertType,
|
||||||
string Severity,
|
string Severity,
|
||||||
string Details,
|
string Details,
|
||||||
|
string ExplanationJson,
|
||||||
string TriggeredAt,
|
string TriggeredAt,
|
||||||
int KafkaPartition,
|
int KafkaPartition,
|
||||||
long KafkaOffset
|
long KafkaOffset
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ public class AlertService : IAlertService
|
|||||||
return AlertResponseMapper.ToResponse(alert);
|
return AlertResponseMapper.ToResponse(alert);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req)
|
public async Task<AlertResponse> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req)
|
||||||
{
|
{
|
||||||
if (!_currentUser.IsAuthenticated)
|
if (!_currentUser.IsAuthenticated)
|
||||||
throw new ValidationException("Authentication required.", "AUTH_REQUIRED");
|
throw new ValidationException("Authentication required.", "AUTH_REQUIRED");
|
||||||
@@ -164,7 +164,7 @@ public class AlertService : IAlertService
|
|||||||
reason: acknowledgmentNote);
|
reason: acknowledgmentNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
return alert;
|
return AlertResponseMapper.ToResponse(alert);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AlertFeedback> SubmitFeedbackAsync(
|
public async Task<AlertFeedback> SubmitFeedbackAsync(
|
||||||
@@ -234,7 +234,7 @@ public class AlertService : IAlertService
|
|||||||
return overrideMinutes ?? defaultWindowMinutes;
|
return overrideMinutes ?? defaultWindowMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ClinicalAlert> ResolveAsync(Guid id)
|
public async Task<AlertResponse> ResolveAsync(Guid id)
|
||||||
{
|
{
|
||||||
var alert = await _db.ClinicalAlerts.FindAsync(id);
|
var alert = await _db.ClinicalAlerts.FindAsync(id);
|
||||||
if (alert is null)
|
if (alert is null)
|
||||||
@@ -256,7 +256,7 @@ public class AlertService : IAlertService
|
|||||||
previousValue: new { status = AlertStatus.Acknowledged.ToDbString() },
|
previousValue: new { status = AlertStatus.Acknowledged.ToDbString() },
|
||||||
newValue: new { status = alert.Status.ToDbString() });
|
newValue: new { status = alert.Status.ToDbString() });
|
||||||
|
|
||||||
return alert;
|
return AlertResponseMapper.ToResponse(alert);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ApplySyncedAcknowledgmentAsync(
|
public async Task ApplySyncedAcknowledgmentAsync(
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ using VigilCare.ClinicalContracts.Sync;
|
|||||||
|
|
||||||
public class ClinicalSyncBatchProcessor
|
public class ClinicalSyncBatchProcessor
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions ExplanationJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IObservationService _observations;
|
private readonly IObservationService _observations;
|
||||||
private readonly IAlertService _alerts;
|
private readonly IAlertService _alerts;
|
||||||
@@ -163,6 +168,7 @@ public class ClinicalSyncBatchProcessor
|
|||||||
AlertType = AlertTypeExtensions.FromDbString(alert.AlertType),
|
AlertType = AlertTypeExtensions.FromDbString(alert.AlertType),
|
||||||
Severity = AlertSeverityExtensions.FromDbString(alert.Severity),
|
Severity = AlertSeverityExtensions.FromDbString(alert.Severity),
|
||||||
Details = alert.Details,
|
Details = alert.Details,
|
||||||
|
Explanation = ParseExplanation(alert.ExplanationJson),
|
||||||
Status = AlertStatus.Open,
|
Status = AlertStatus.Open,
|
||||||
TriggeredAt = alert.GeneratedAt
|
TriggeredAt = alert.GeneratedAt
|
||||||
};
|
};
|
||||||
@@ -172,7 +178,7 @@ public class ClinicalSyncBatchProcessor
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Topic = "alert.generated",
|
Topic = "alert.generated",
|
||||||
Payload = JsonSerializer.Serialize(new
|
Payload = ClinicalAlertFactory.SerializeOutboxPayload(new
|
||||||
{
|
{
|
||||||
alertId = clinicalAlert.Id,
|
alertId = clinicalAlert.Id,
|
||||||
encounterId = alert.EncounterId,
|
encounterId = alert.EncounterId,
|
||||||
@@ -180,6 +186,7 @@ public class ClinicalSyncBatchProcessor
|
|||||||
alertType = alert.AlertType,
|
alertType = alert.AlertType,
|
||||||
severity = alert.Severity,
|
severity = alert.Severity,
|
||||||
details = alert.Details,
|
details = alert.Details,
|
||||||
|
explanation = clinicalAlert.Explanation,
|
||||||
syncedFromGateway = true,
|
syncedFromGateway = true,
|
||||||
triggeredAt = alert.GeneratedAt,
|
triggeredAt = alert.GeneratedAt,
|
||||||
partitionKey = alert.EncounterId.ToString()
|
partitionKey = alert.EncounterId.ToString()
|
||||||
@@ -231,4 +238,9 @@ public class ClinicalSyncBatchProcessor
|
|||||||
_db.ClinicalSyncConflicts.Add(new ClinicalSyncConflict(batchId, clientRef, itemType, reason));
|
_db.ClinicalSyncConflicts.Add(new ClinicalSyncConflict(batchId, clientRef, itemType, reason));
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AlertExplanation? ParseExplanation(string? explanationJson) =>
|
||||||
|
string.IsNullOrWhiteSpace(explanationJson)
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Deserialize<AlertExplanation>(explanationJson, ExplanationJsonOptions);
|
||||||
}
|
}
|
||||||
@@ -8,9 +8,9 @@ public interface IAlertService
|
|||||||
|
|
||||||
Task<AlertResponse> GetByIdAsync(Guid id);
|
Task<AlertResponse> GetByIdAsync(Guid id);
|
||||||
|
|
||||||
Task<ClinicalAlert> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req);
|
Task<AlertResponse> AcknowledgeAsync(Guid id, AcknowledgeAlertRequest req);
|
||||||
|
|
||||||
Task<ClinicalAlert> ResolveAsync(Guid id);
|
Task<AlertResponse> ResolveAsync(Guid id);
|
||||||
Task ApplySyncedAcknowledgmentAsync(Guid alertId, SyncedAlertAcknowledgment ack, CancellationToken ct);
|
Task ApplySyncedAcknowledgmentAsync(Guid alertId, SyncedAlertAcknowledgment ack, CancellationToken ct);
|
||||||
Task ApplySyncedResolutionAsync(Guid alertId, SyncedAlertResolution resolve, CancellationToken ct);
|
Task ApplySyncedResolutionAsync(Guid alertId, SyncedAlertResolution resolve, CancellationToken ct);
|
||||||
Task<AlertFeedback> SubmitFeedbackAsync(Guid alertId, AlertFeedbackType type, string? comment);
|
Task<AlertFeedback> SubmitFeedbackAsync(Guid alertId, AlertFeedbackType type, string? comment);
|
||||||
|
|||||||
@@ -24,16 +24,16 @@ Each priority is scored on three dimensions using a 1-10 scale.
|
|||||||
|
|
||||||
## Summary Matrix
|
## Summary Matrix
|
||||||
|
|
||||||
| Phase | Feature | Commercial | Clinical | Composite | Dependencies |
|
| Phase | Feature | Commercial | Clinical | Composite | Dependencies | Status |
|
||||||
|-------|------------------------------------|------------|----------|-----------|--------------------|
|
|-------|------------------------------------|------------|----------|-----------|--------------------|-----------|
|
||||||
| 33 | Alert Quality Analytics | 10 | 9 | 9.5 | None |
|
| 33 | Alert Quality Analytics | 10 | 9 | 9.5 | None | — |
|
||||||
| 34 | Explainable Alerts | 8 | 10 | 9.0 | None |
|
| 34 | Explainable Alerts | 8 | 10 | 9.0 | None | Complete |
|
||||||
| 35 | Alert Lifecycle Analytics | 9 | 7 | 8.0 | Phase 33 |
|
| 35 | Alert Lifecycle Analytics | 9 | 7 | 8.0 | Phase 33 | — |
|
||||||
| 36 | Role-Based Alert Routing | 8 | 9 | 8.5 | Phase 33, 35 |
|
| 36 | Role-Based Alert Routing | 8 | 9 | 8.5 | Phase 33, 35 | — |
|
||||||
| 37 | Alert Bundling and Correlation | 7 | 8 | 7.5 | Phase 34 |
|
| 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) |
|
| 38 | Adaptive Threshold Recommendations | 9 | 6 | 7.5 | Phase 33, 35 (months of data) | — |
|
||||||
| 39 | Scoring Framework Abstraction | 5 | 4 | 4.5 | None |
|
| 39 | Scoring Framework Abstraction | 5 | 4 | 4.5 | None | — |
|
||||||
| 40 | MEWS | 5 | 5 | 5.0 | Phase 39 |
|
| 40 | MEWS | 5 | 5 | 5.0 | Phase 39 | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
| `alert_type` | `alertType` | e.g. `WARNING_HEART_RATE`, `CRITICAL_HEART_RATE` |
|
||||||
| `severity` | `severity` | e.g. `Warning`, `Critical` |
|
| `severity` | `severity` | e.g. `Warning`, `Critical` |
|
||||||
| `details` | `details` | Required on all new alert producers; parser defaults to `""` if absent |
|
| `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 |
|
| `triggered_at` | `triggeredAt` | ISO-8601 |
|
||||||
|
|
||||||
Phase 11 aligned warning and sepsis outbox producers with critical ingest alerts by
|
Phase 11 aligned warning and sepsis outbox producers with critical ingest alerts by
|
||||||
|
|||||||
@@ -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` |
|
| 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 Center | `GET /alerts?status=OPEN` |
|
||||||
| Alert actions | `POST /alerts/{id}/acknowledge`, `POST /alerts/{id}/resolve` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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 |
|
| 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` |
|
| 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 |
|
| Clinician paged | No | Kafka → notification publisher → RabbitMQ paging queue | Alert `status` field |
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
|
|
||||||
|
export function fetchAlertById(alertId) {
|
||||||
|
return api.get(`/api/v1/alerts/${alertId}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAlerts(encounterId, status) {
|
export function fetchAlerts(encounterId, status) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (status) params.set('status', status)
|
if (status) params.set('status', status)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useAlertStore } from '@/stores/alerts'
|
|||||||
import { useScoringStore } from '@/stores/scoring'
|
import { useScoringStore } from '@/stores/scoring'
|
||||||
import * as encountersApi from '@/api/encounters'
|
import * as encountersApi from '@/api/encounters'
|
||||||
import * as clinicalApi from '@/api/clinical'
|
import * as clinicalApi from '@/api/clinical'
|
||||||
|
import * as alertsApi from '@/api/alerts'
|
||||||
import PatientBanner from '@/components/patient/PatientBanner.vue'
|
import PatientBanner from '@/components/patient/PatientBanner.vue'
|
||||||
import VitalsPanel from '@/components/patient/VitalsPanel.vue'
|
import VitalsPanel from '@/components/patient/VitalsPanel.vue'
|
||||||
import ScoresPanel from '@/components/patient/ScoresPanel.vue'
|
import ScoresPanel from '@/components/patient/ScoresPanel.vue'
|
||||||
@@ -157,8 +158,12 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectAlert(alert) {
|
async function onSelectAlert(alert) {
|
||||||
|
try {
|
||||||
|
selectedAlert.value = await alertsApi.fetchAlertById(alert.id)
|
||||||
|
} catch {
|
||||||
selectedAlert.value = alert
|
selectedAlert.value = alert
|
||||||
|
}
|
||||||
jumpToTimestamp(alert.triggeredAt)
|
jumpToTimestamp(alert.triggeredAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user