chore: necessary updates given the changes in the alert controller

This commit is contained in:
voltsrage
2026-06-25 00:40:47 +08:00
parent 666d683d67
commit 7bb9124230
32 changed files with 353 additions and 40 deletions
@@ -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 912 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);
+2 -1
View File
@@ -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" }
}
}
},