chore: necessary updates given the changes in the alert controller
This commit is contained in:
@@ -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(
|
||||
Guid Id, string AlertType, string Severity, string Status,
|
||||
string Details, DateTimeOffset TriggeredAt);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<ApiResponse<PagedResponse<AlertResponse>>>();
|
||||
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>(ApiJsonOptions);
|
||||
return envelope?.Data?.Items?.ToList() ?? new();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ public class ReplayResult
|
||||
public int OrdersPlaced { get; set; }
|
||||
public int OrdersResulted { 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
"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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"afterOffsetMinutes": 130,
|
||||
"type": "alert",
|
||||
"alertType": "SOFA_SEPSIS",
|
||||
"narrativeContains": "SOFA",
|
||||
"description": "SOFA delta ≥ 2 from baseline after labs + vasopressor"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,4 +25,5 @@ public record ScenarioEvent(
|
||||
public record ExpectedOutcome(
|
||||
double AfterOffsetMinutes, string Type,
|
||||
string? AlertType, string? ScoreType,
|
||||
double? ExpectedMinimum, string? Description);
|
||||
double? ExpectedMinimum, string? Description,
|
||||
string? NarrativeContains = null);
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user