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,34 @@
namespace VigilCare.Simulation;
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; }
}
@@ -0,0 +1,21 @@
namespace VigilCare.Simulation;
public record AlertResponse(
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;
}
@@ -0,0 +1,5 @@
namespace VigilCare.Simulation;
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error);
public record ApiError(string Message, string Code);
public record PagedResponse<T>(List<T> Items, int TotalCount, int Page, int PageSize);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record BatchIngestRequest(List<IngestObservationRequest> Observations);
@@ -0,0 +1,5 @@
namespace VigilCare.Simulation;
public record CreateMedicationAdministrationRequest(
string DrugName, decimal Dose, string DoseUnit, string Route,
DateTimeOffset? AdministeredAt, string AdministeredBy);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record CreateOrderRequest(string OrderType, string Description, string OrderedBy);
@@ -0,0 +1,6 @@
namespace VigilCare.Simulation;
public record EncounterResponse(
Guid Id, Guid PatientId, string EncounterType, string Status,
string Department, string AttendingPhysician, string? RoomBed,
string? AdmissionReason, DateTimeOffset AdmittedAt);
@@ -0,0 +1,5 @@
namespace VigilCare.Simulation;
public record GcsResponse(
int EyeScore, int VerbalScore, int MotorScore,
int TotalScore, string Classification, DateTimeOffset CalculatedAt);
@@ -0,0 +1,6 @@
namespace VigilCare.Simulation;
public record IngestObservationRequest(
string ObservationCode, decimal Value, string Unit,
string Source, DateTimeOffset RecordedAt, string? IdempotencyKey);
@@ -0,0 +1,11 @@
namespace VigilCare.Simulation;
public record SimLoginRequest(string Username, string Password);
public record SimLoginResponse(
string AccessToken,
DateTimeOffset ExpiresAt,
Guid UserId,
string Username,
string DisplayName,
string Role);
@@ -0,0 +1,7 @@
namespace VigilCare.Simulation;
public record News2Response(
Guid Id, int TotalScore, string RiskLevel, bool HasSingleParamThree,
int RespRateScore, int Spo2Score, int SystolicBpScore,
int HeartRateScore, int ConsciousnessScore, int TemperatureScore,
int SupplementalO2Score, DateTimeOffset CalculatedAt);
@@ -0,0 +1,6 @@
namespace VigilCare.Simulation;
public record OpenEncounterRequest(
string EncounterType, string Department, string AttendingPhysician,
string? RoomBed = null, string? AdmissionReason = null);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record OrderResponse(Guid Id, string Description, string Status);
@@ -0,0 +1,5 @@
namespace VigilCare.Simulation;
public record PatientResponse(
Guid Id, string Mrn, string FirstName, string LastName,
DateOnly DateOfBirth, string Gender, string Status, DateTimeOffset CreatedAt);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record QsofaResponse(int ActiveCriteria);
@@ -0,0 +1,3 @@
namespace VigilCare.Simulation;
public record RecordOrderResultRequest(string? ResultSummary);
@@ -0,0 +1,4 @@
namespace VigilCare.Simulation;
public record RegisterPatientRequest(
string FirstName, string LastName, DateOnly DateOfBirth, string Gender);
@@ -0,0 +1,13 @@
namespace VigilCare.Simulation;
public record SepsisBundleElementResponse(string Status);
public record SepsisBundleResponse(
Guid Id, string TriggeringAlertType, string ComplianceStatus,
DateTimeOffset RecognizedAt, DateTimeOffset DeadlineAt,
DateTimeOffset? CompletedAt,
List<SepsisBundleElementResponse>? Elements = null)
{
public int ElementsCompleted =>
Elements?.Count(e => e.Status == "Completed") ?? 0;
}
@@ -0,0 +1,9 @@
namespace VigilCare.Simulation;
public record SofaResponse(
int TotalScore,
int RespiratoryScore, int CoagulationScore, int LiverScore,
int CardiovascularScore, int CnsScore, int RenalScore,
bool IsBaseline, int? DeltaFromBaseline,
SofaStalenessResponse? Staleness,
DateTimeOffset CalculatedAt);
@@ -0,0 +1,6 @@
namespace VigilCare.Simulation;
public record SofaStalenessResponse(
IReadOnlyList<string> StaleComponents,
IReadOnlyList<string> MissingComponents,
bool UsedSpO2Fallback);
@@ -0,0 +1,251 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace VigilCare.Simulation;
public class VigilCareApiClient
{
private static readonly JsonSerializerOptions ApiJsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _http;
public VigilCareApiClient(HttpClient http)
{
_http = http;
}
public async Task LoginAsync(string username, string password)
{
var response = await _http.PostAsJsonAsync("/api/v1/auth/login",
new SimLoginRequest(username, password));
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Login failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SimLoginResponse>>();
var token = envelope!.Data!.AccessToken;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
public void SetBearerToken(string token)
{
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
public async Task<PatientResponse> RegisterPatientAsync(RegisterPatientRequest req)
{
var response = await _http.PostAsJsonAsync("/api/v1/patients", req);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Register patient failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<PatientResponse>>();
return envelope!.Data!;
}
public async Task<EncounterResponse> OpenEncounterAsync(Guid patientId, OpenEncounterRequest req)
{
var response = await _http.PostAsJsonAsync($"/api/v1/patients/{patientId}/encounters", req);
response.EnsureSuccessStatusCode();
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<EncounterResponse>>();
return envelope!.Data!;
}
public async Task SendObservationBatchAsync(
Guid encounterId, List<IngestObservationRequest> observations,
ReplayTarget target = ReplayTarget.Central)
{
if (target == ReplayTarget.Gateway)
{
foreach (var obs in observations)
{
var response = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/observations",
new
{
observationCode = obs.ObservationCode,
value = obs.Value,
unit = obs.Unit,
source = obs.Source,
recordedAt = obs.RecordedAt,
idempotencyKey = obs.IdempotencyKey ?? Guid.NewGuid().ToString()
});
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Observation ingest failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
}
return;
}
foreach (var chunk in observations.Chunk(10))
{
var batch = new BatchIngestRequest(chunk.ToList());
var response = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/observations", batch);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Observation batch failed ({(int)response.StatusCode} {response.StatusCode}): {body}");
}
}
}
public async Task<bool> TrySendMedicationAsync(
Guid encounterId, CreateMedicationAdministrationRequest req)
{
var response = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/medications", req);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return true;
}
public async Task<bool> TryCreateOrderAsync(
Guid encounterId, CreateOrderRequest req)
{
var response = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/orders", req);
return response.IsSuccessStatusCode;
}
public async Task<bool> TryResultOrderAsync(
Guid encounterId, string orderDescription, string? resultSummary)
{
var ordersResponse = await _http.GetAsync(
$"/api/v1/encounters/{encounterId}/orders?status=PENDING");
if (!ordersResponse.IsSuccessStatusCode)
return false;
var envelope = await ordersResponse.Content
.ReadFromJsonAsync<ApiResponse<PagedResponse<OrderResponse>>>();
var order = FindPendingOrder(envelope?.Data?.Items ?? [], orderDescription);
if (order is null)
return false;
var resultResponse = await _http.PatchAsJsonAsync(
$"/api/v1/orders/{order.Id}/result",
new RecordOrderResultRequest(resultSummary));
return resultResponse.IsSuccessStatusCode;
}
private static OrderResponse? FindPendingOrder(
List<OrderResponse> items, string description)
{
return items.FirstOrDefault(o => o.Description == description)
?? items.FirstOrDefault(o =>
description.StartsWith(o.Description, StringComparison.OrdinalIgnoreCase))
?? items.FirstOrDefault(o =>
o.Description.StartsWith(description, StringComparison.OrdinalIgnoreCase));
}
// --- Polling endpoints ---
public async Task<List<AlertResponse>> GetAlertsAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/alerts");
if (!response.IsSuccessStatusCode) return new();
var envelope = await response.Content
.ReadFromJsonAsync<ApiResponse<PagedResponse<AlertResponse>>>(ApiJsonOptions);
return envelope?.Data?.Items?.ToList() ?? new();
}
public async Task<News2Response?> GetCurrentNews2Async(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/news2/current");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<News2Response>>();
return envelope?.Data;
}
public async Task<SepsisBundleResponse?> GetSepsisBundleAsync(Guid encounterId)
{
var response = await _http.GetAsync(
$"/api/v1/encounters/{encounterId}/sepsis-bundle/current");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content
.ReadFromJsonAsync<ApiResponse<SepsisBundleResponse>>();
return envelope?.Data;
}
public async Task<GcsResponse?> GetCurrentGcsAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/gcs");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<GcsResponse>>();
return envelope?.Data;
}
public async Task<SofaResponse?> GetCurrentSofaAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/sofa");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<SofaResponse>>();
return envelope?.Data;
}
public async Task<QsofaResponse?> GetCurrentQsofaAsync(Guid encounterId)
{
var response = await _http.GetAsync($"/api/v1/encounters/{encounterId}/qsofa/current");
if (!response.IsSuccessStatusCode) return null;
var envelope = await response.Content.ReadFromJsonAsync<ApiResponse<QsofaResponse>>();
return envelope?.Data;
}
public async Task<bool> TryAcknowledgeAlertAsync(
Guid encounterId, string alertType, string clinicianId, string? note,
TimeSpan? waitForAlert = null, CancellationToken ct = default)
{
const int pollIntervalMs = 500;
var deadline = waitForAlert.HasValue
? DateTimeOffset.UtcNow.Add(waitForAlert.Value)
: (DateTimeOffset?)null;
while (true)
{
ct.ThrowIfCancellationRequested();
var alerts = await GetAlertsAsync(encounterId);
var alert = alerts.FirstOrDefault(a =>
IsMatchingAlertType(a.AlertType, alertType)
&& IsOpenAlertStatus(a.Status));
if (alert is not null)
{
var response = await _http.PostAsJsonAsync(
$"/api/v1/alerts/{alert.Id}/acknowledge",
new { note, clinicianId }, ct);
return response.IsSuccessStatusCode;
}
if (!deadline.HasValue || DateTimeOffset.UtcNow >= deadline.Value)
return false;
await Task.Delay(pollIntervalMs, ct);
}
}
private static bool IsOpenAlertStatus(string status) =>
string.Equals(status, "OPEN", StringComparison.OrdinalIgnoreCase);
private static bool IsMatchingAlertType(string actual, string expected)
{
if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
return true;
static string Normalize(string s) =>
s.Replace("_", "", StringComparison.Ordinal).ToUpperInvariant();
return Normalize(actual) == Normalize(expected);
}
}