feature: In-App Simulation Runner (Backend)
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 8m32s

This commit is contained in:
voltsrage
2026-08-06 01:52:53 +08:00
parent 943d41339c
commit 24f45851e9
83 changed files with 3974 additions and 120 deletions
@@ -0,0 +1,23 @@
namespace VigilCare.Simulation;
public static class DepartmentMapper
{
private static readonly Dictionary<string, string> ScenarioToApi = new(StringComparer.OrdinalIgnoreCase)
{
["Icu"] = "ICU",
["GeneralMedicine"] = "GENERAL_MEDICINE",
["Emergency"] = "EMERGENCY",
["Cardiology"] = "CARDIOLOGY",
["Surgery"] = "SURGERY",
["Pediatrics"] = "PEDIATRICS",
};
public static string ToApiDepartment(string scenarioDepartment)
{
if (ScenarioToApi.TryGetValue(scenarioDepartment, out var apiValue))
return apiValue;
throw new InvalidOperationException(
$"Unknown scenario department '{scenarioDepartment}'. Expected one of: {string.Join(", ", ScenarioToApi.Keys)}.");
}
}
@@ -0,0 +1,152 @@
namespace VigilCare.Simulation;
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
};
}
}
@@ -0,0 +1,31 @@
using System.Text.Json;
namespace VigilCare.Simulation;
public record ScenarioFile(
ScenarioMeta Scenario,
ScenarioPatient Patient,
ScenarioEncounter Encounter,
List<ScenarioEvent> Events,
List<ExpectedOutcome>? ExpectedOutcomes);
public record ScenarioMeta(
string Id, string Name, string? Description,
int? DurationMinutes, List<string>? Tags);
public record ScenarioPatient(
string FirstName, string LastName, string DateOfBirth,
string Gender);
public record ScenarioEncounter(
string Department, string EncounterType, string AttendingPhysician,
string? RoomBed, string? AdmissionReason);
public record ScenarioEvent(
double OffsetMinutes, string Type, JsonElement Data, string? Note);
public record ExpectedOutcome(
double AfterOffsetMinutes, string Type,
string? AlertType, string? ScoreType,
double? ExpectedMinimum, string? Description,
string? NarrativeContains = null);
@@ -0,0 +1,59 @@
using System.Text.Json;
namespace VigilCare.Simulation;
public static class ScenarioLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static ScenarioFile Load(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Scenario file not found: {path}");
var json = File.ReadAllText(path);
var scenario = JsonSerializer.Deserialize<ScenarioFile>(json, JsonOptions)
?? throw new InvalidOperationException($"Failed to deserialize: {path}");
return scenario with
{
Events = scenario.Events.OrderBy(e => e.OffsetMinutes).ToList()
};
}
/// <summary>
/// Enumerates <c>*.json</c> in <paramref name="directory"/>, skips files that
/// fail to deserialize, and returns pairs sorted by <see cref="ScenarioMeta.Id"/>.
/// </summary>
public static IReadOnlyList<(ScenarioFile Scenario, string Path)> LoadAll(string directory)
{
if (!Directory.Exists(directory))
return Array.Empty<(ScenarioFile, string)>();
var results = new List<(ScenarioFile Scenario, string Path)>();
foreach (var path in Directory.EnumerateFiles(directory, "*.json")
.Where(p => !string.Equals(
Path.GetFileName(p), "schema.json", StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
{
try
{
results.Add((Load(path), path));
}
catch
{
// Skip files that fail to deserialize — catalogue must stay resilient.
}
}
return results
.OrderBy(r => r.Scenario.Scenario.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
@@ -0,0 +1,158 @@
namespace VigilCare.Simulation;
public static class ScenarioValidator
{
private static readonly HashSet<string> ValidCodes = new()
{
"HEART_RATE", "RESP_RATE", "SYSTOLIC_BP", "DIASTOLIC_BP",
"TEMP_C", "SPO2", "AVPU", "SUPPLEMENTAL_O2",
"WBC_K_UL", "POTASSIUM_MEQ_L", "LACTATE_MMOL_L", "GLUCOSE_MG_DL",
// Phase 25 — GCS components
"GCS_EYE", "GCS_VERBAL", "GCS_MOTOR",
// Phase 26 — SOFA lab / respiratory inputs
"PAO2_MMHG", "FIO2_PCT", "PLATELET_K_UL",
"BILIRUBIN_MG_DL", "CREATININE_MG_DL", "URINE_OUTPUT_ML_H",
};
private static readonly HashSet<string> ValidSources = new()
{
"Manual", "Device", "Lab"
};
private static readonly HashSet<string> ValidDepartments = new()
{
"Icu", "GeneralMedicine", "Emergency", "Cardiology", "Surgery", "Pediatrics"
};
private static readonly HashSet<string> ValidEncounterTypes = new()
{
"Inpatient", "Outpatient", "Emergency"
};
private static readonly HashSet<string> ValidEventTypes = new()
{
"observation", "order", "medication", "order_result", "alert_ack"
};
private static readonly HashSet<string> ValidOrderTypes = new()
{
"Lab", "Imaging", "Medication", "Procedure"
};
private static readonly string[] SepsisBundleOrderPrefixes =
[
"SEP-1: Blood cultures",
"SEP-1: Serum lactate",
"SEP-1: Broad-spectrum antibiotics",
"SEP-1: IV fluid bolus"
];
public static List<string> Validate(ScenarioFile scenario)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(scenario.Scenario.Id))
errors.Add("scenario.id is required");
if (string.IsNullOrWhiteSpace(scenario.Scenario.Name))
errors.Add("scenario.name is required");
if (string.IsNullOrWhiteSpace(scenario.Encounter.AttendingPhysician))
errors.Add("encounter.attendingPhysician is required");
if (!ValidDepartments.Contains(scenario.Encounter.Department))
errors.Add($"encounter.department '{scenario.Encounter.Department}' is not valid");
if (!ValidEncounterTypes.Contains(scenario.Encounter.EncounterType))
errors.Add($"encounter.encounterType '{scenario.Encounter.EncounterType}' is not valid");
if (scenario.Events.Count == 0)
errors.Add("events array is empty");
foreach (var group in scenario.Events
.Where(e => e.Type == "observation")
.GroupBy(e => e.OffsetMinutes))
{
if (group.Count() > 10)
errors.Add(
$"offsetMinutes {group.Key}: {group.Count()} observations exceeds API batch limit of 10");
}
var placedOrders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var indexedEvents = scenario.Events
.Select((evt, i) => (evt, i))
.OrderBy(x => x.evt.OffsetMinutes)
.ThenBy(x => x.i);
foreach (var (evt, i) in indexedEvents)
{
if (evt.OffsetMinutes < 0)
errors.Add($"events[{i}]: offsetMinutes cannot be negative");
if (!ValidEventTypes.Contains(evt.Type))
errors.Add($"events[{i}]: unknown type '{evt.Type}'");
if (evt.Type == "observation")
{
var code = evt.Data.TryGetProperty("code", out var codeProp) ? codeProp.GetString() : null;
if (code is null || !ValidCodes.Contains(code))
errors.Add($"events[{i}]: unknown observation code '{code}'");
var source = evt.Data.TryGetProperty("source", out var sourceProp) ? sourceProp.GetString() : null;
if (source is not null && !ValidSources.Contains(source))
errors.Add($"events[{i}]: unknown observation source '{source}'");
}
if (evt.Type == "order")
{
var description = evt.Data.TryGetProperty("description", out var descProp)
? descProp.GetString() : null;
if (string.IsNullOrWhiteSpace(description))
errors.Add($"events[{i}]: order.description is required");
var orderType = evt.Data.TryGetProperty("orderType", out var typeProp)
? typeProp.GetString() : null;
if (orderType is null || !ValidOrderTypes.Contains(orderType))
errors.Add($"events[{i}]: order.orderType '{orderType}' is not valid");
if (!string.IsNullOrWhiteSpace(description))
placedOrders.Add(description);
}
if (evt.Type == "order_result")
{
var orderDescription = evt.Data.TryGetProperty("orderDescription", out var descProp)
? descProp.GetString() : null;
if (string.IsNullOrWhiteSpace(orderDescription))
{
errors.Add($"events[{i}]: order_result.orderDescription is required");
continue;
}
if (!placedOrders.Contains(orderDescription)
&& !IsSepsisBundleOrder(orderDescription)
&& !placedOrders.Any(p => orderDescription.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
{
errors.Add(
$"events[{i}]: order_result '{orderDescription}' has no matching prior 'order' event " +
"(sepsis bundle orders are auto-created when SOFA_SEPSIS or QSOFA_SCREEN fires)");
}
}
if (evt.Type == "alert_ack")
{
var alertType = evt.Data.TryGetProperty("alertType", out var alertTypeProp)
? alertTypeProp.GetString() : null;
if (string.IsNullOrWhiteSpace(alertType))
errors.Add($"events[{i}]: alert_ack.alertType is required");
var clinicianId = evt.Data.TryGetProperty("clinicianId", out var clinicianProp)
? clinicianProp.GetString() : null;
if (string.IsNullOrWhiteSpace(clinicianId))
errors.Add($"events[{i}]: alert_ack.clinicianId is required");
}
}
return errors;
}
private static bool IsSepsisBundleOrder(string description) =>
SepsisBundleOrderPrefixes.Any(p =>
description.Equals(p, StringComparison.OrdinalIgnoreCase)
|| description.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}