Rebuild CCS case workflow

This commit is contained in:
hz
2026-07-23 04:43:02 -05:00
parent 489b9c3d7c
commit 8e216eac32
13 changed files with 1374 additions and 690 deletions
+35
View File
@@ -8,6 +8,41 @@ namespace QuestionsApi.Tests;
public sealed class CcsCaseTests
{
[Fact]
public void NewCcsAttemptsAreTimedOnly()
{
Assert.Null(CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest()));
Assert.Equal("new CCS attempts must be timed",
CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest { Mode = CcsAttemptModes.Practice }));
}
[Fact]
public void CcsBatchCommandsAreBoundedAndHomogeneous()
{
var valid = new CcsBatchCommandRequest
{
CommandId = "batch-1",
Commands =
[
new() { Type = "exam", Code = "general" },
new() { Type = "exam", Code = "cardiac" }
]
};
Assert.Null(CcsCaseValidator.ValidateBatch(valid));
valid.Commands.Add(new() { Type = "order", Code = "bmp" });
Assert.Contains("cannot be mixed", CcsCaseValidator.ValidateBatch(valid), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CcsTimingReservesTwoMinutesWithoutExtendingTheHardDeadline()
{
Assert.Equal(18, CcsStore.ActivePhaseMinutes(20));
Assert.Equal(8, CcsStore.ActivePhaseMinutes(10));
var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
Assert.Equal(now.AddMinutes(2), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(5)));
Assert.Equal(now.AddMinutes(1), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(1)));
}
[Fact]
public void CcsHistoryCursorRoundTripsTimestampAndId()
{
@@ -51,6 +51,16 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempts/{id}/commands/batch")]
public async Task<IActionResult> BatchCommand(string id, CcsBatchCommandRequest request)
{
var attempt = await store.GetOwnedAsync(id, UserId());
if (attempt is null) return NotFound();
try { return Ok(await store.ToResponseAsync(await store.ApplyBatchCommandAsync(attempt, request))); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempts/{id}/abandon")]
public async Task<IActionResult> Abandon(string id)
{
+80 -2
View File
@@ -17,6 +17,26 @@ public static class CcsAttemptModes
public const string Timed = "timed";
}
public static class CcsChartCategories
{
public const string Orders = "orders";
public const string Progress = "progress";
public const string Vitals = "vitals";
public const string Lab = "lab";
public const string Imaging = "imaging";
public const string OtherTest = "other_test";
public const string Treatment = "treatment";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Orders, Progress, Vitals, Lab, Imaging, OtherTest, Treatment
};
public static readonly IReadOnlySet<string> ResultSections = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Lab, Imaging, OtherTest, Treatment
};
}
public class CcsOrderDefinition
{
[BsonId] public string Code { get; set; } = "";
@@ -25,6 +45,9 @@ public class CcsOrderDefinition
public List<string> Aliases { get; set; } = [];
public int DefaultTurnaroundMinutes { get; set; }
public List<string> AvailableLocations { get; set; } = [];
public string DisplayRoute { get; set; } = "";
public string DisplayFrequency { get; set; } = "";
public string ResultSection { get; set; } = "";
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -57,11 +80,19 @@ public class CcsInitialChart
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public Dictionary<string, string> Vitals { get; set; } = [];
public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow;
public List<string> InitialOrderCodes { get; set; } = [];
}
public class CcsNarrativeSection
{
public string Code { get; set; } = "";
public string Title { get; set; } = "";
public string Text { get; set; } = "";
}
public class CcsExamDefinition
{
public string Code { get; set; } = "";
@@ -75,6 +106,7 @@ public class CcsCaseOrderDefinition
public string OrderCode { get; set; } = "";
public int? TurnaroundMinutes { get; set; }
public string Result { get; set; } = "";
public string ResultSection { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
}
@@ -111,19 +143,22 @@ public class CcsAttempt
public string CaseExternalId { get; set; } = "";
[BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = "";
public int CaseVersion { get; set; }
public string Mode { get; set; } = CcsAttemptModes.Practice;
public string Mode { get; set; } = CcsAttemptModes.Timed;
public string State { get; set; } = CcsAttemptStates.Active;
public int Revision { get; set; }
public int SimulatedMinute { get; set; }
[BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; }
public string Location { get; set; } = "";
public Dictionary<string, string> CurrentVitals { get; set; } = [];
public List<CcsAttemptAction> Actions { get; set; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = [];
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = [];
public List<string> ProcessedEventIds { get; set; } = [];
public List<string> ProcessedCommandIds { get; set; } = [];
public DateTime? DeadlineUtc { get; set; }
public DateTime? HardDeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
@@ -144,6 +179,8 @@ public class CcsAttemptAction
public class CcsPendingResult
{
public string OrderCode { get; set; } = "";
public string Category { get; set; } = "";
public string Title { get; set; } = "";
public int DueMinute { get; set; }
public string Result { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
@@ -153,9 +190,30 @@ public class CcsChartEntry
{
public string Category { get; set; } = "progress";
public int SimulatedMinute { get; set; }
public string Code { get; set; } = "";
public string Title { get; set; } = "";
public string Text { get; set; } = "";
}
public class CcsVitalSnapshot
{
public int SimulatedMinute { get; set; }
public Dictionary<string, string> Values { get; set; } = [];
}
public class CcsActiveOrder
{
public string Code { get; set; } = "";
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public string Route { get; set; } = "";
public string Frequency { get; set; } = "";
public int OrderedMinute { get; set; }
public int? ReportMinute { get; set; }
public bool IsPending { get; set; }
public bool HasResult { get; set; }
}
public class CcsCaseScore
{
public double Percent { get; set; }
@@ -194,7 +252,7 @@ public class CcsImportResponse
public class CreateCcsAttemptRequest
{
public string CaseExternalId { get; set; } = "";
public string Mode { get; set; } = CcsAttemptModes.Practice;
public string Mode { get; set; } = CcsAttemptModes.Timed;
}
public class CcsCommandRequest
@@ -206,6 +264,20 @@ public class CcsCommandRequest
public int Minutes { get; set; }
}
public class CcsBatchCommandRequest
{
public string CommandId { get; set; } = "";
public int ExpectedRevision { get; set; }
public List<CcsBatchCommandItem> Commands { get; set; } = [];
}
public class CcsBatchCommandItem
{
public string Type { get; set; } = "";
public string Code { get; set; } = "";
public int Minutes { get; set; }
}
public class CcsCaseSummary
{
public string ExternalId { get; set; } = "";
@@ -226,8 +298,11 @@ public class CcsAttemptResponse
public string State { get; set; } = "";
public int Revision { get; set; }
public int SimulatedMinute { get; set; }
public DateTime SimulatedStartUtc { get; set; }
public bool OpeningCompleted { get; set; }
public string Location { get; set; } = "";
public DateTime? DeadlineUtc { get; set; }
public DateTime? HardDeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; }
@@ -236,9 +311,12 @@ public class CcsAttemptResponse
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public Dictionary<string, string> Vitals { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = [];
public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = [];
+34 -3
View File
@@ -4,8 +4,7 @@ namespace QuestionsApi.Services;
public static class CcsCaseValidator
{
private static readonly HashSet<string> Modes = [CcsAttemptModes.Practice, CcsAttemptModes.Timed];
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "end_case", "exit_case"];
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{
@@ -18,6 +17,8 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required");
if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required");
if (order.DefaultTurnaroundMinutes < 0) errors.Add(prefix + "defaultTurnaroundMinutes cannot be negative");
if (!string.IsNullOrWhiteSpace(order.ResultSection) && !CcsChartCategories.ResultSections.Contains(order.ResultSection))
errors.Add(prefix + "resultSection is invalid");
}
if (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count)
errors.Add("order codes must be unique within an import");
@@ -50,7 +51,11 @@ public static class CcsCaseValidator
if (eventIds.Any(string.IsNullOrWhiteSpace) || eventIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != eventIds.Count)
errors.Add(prefix + "event ids must be present and unique");
foreach (var response in item.OrderResponses)
{
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
}
foreach (var code in item.InitialChart.InitialOrderCodes.Concat(item.Events.SelectMany(value => value.RequiresOrderCodes.Concat(value.RequiresAnyOrderCodes))))
if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}");
var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList();
@@ -75,7 +80,8 @@ public static class CcsCaseValidator
public static string? ValidateCreate(CreateCcsAttemptRequest request)
{
if (!Modes.Contains(request.Mode)) return "mode must be practice or timed";
if (!request.Mode.Equals(CcsAttemptModes.Timed, StringComparison.OrdinalIgnoreCase))
return "new CCS attempts must be timed";
return null;
}
@@ -88,4 +94,29 @@ public static class CcsCaseValidator
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
return null;
}
public static string? ValidateBatch(CcsBatchCommandRequest request)
{
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (request.Commands.Count is < 1 or > 50) return "commands must contain between 1 and 50 items";
foreach (var item in request.Commands)
{
var error = ValidateCommand(new CcsCommandRequest
{
CommandId = request.CommandId,
ExpectedRevision = request.ExpectedRevision,
Type = item.Type,
Code = item.Code,
Minutes = item.Minutes
});
if (error is not null) return error;
if (item.Type is not ("exam" or "interval_history" or "order" or "cancel_order"))
return "batch commands support history/exams, orders, and order cancellations only";
}
var hasClinicalAssessment = request.Commands.Any(value => value.Type is "exam" or "interval_history");
if (hasClinicalAssessment && request.Commands.Any(value => value.Type is not ("exam" or "interval_history")))
return "history and examinations cannot be mixed with order changes in one batch";
return null;
}
}
+206 -22
View File
@@ -115,16 +115,22 @@ public sealed class CcsStore
CaseExternalId = definition.ExternalId,
CaseRevisionId = definition.Id ?? "",
CaseVersion = definition.Version,
Mode = request.Mode,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
[
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
],
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null,
RatingEligible = request.Mode == CcsAttemptModes.Timed,
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = true,
Chart =
[
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter },
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance }
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "initial_appearance", Title = "Initial appearance", Text = definition.InitialChart.Appearance }
]
};
try { await _attempts.InsertOneAsync(attempt); }
@@ -173,7 +179,12 @@ public sealed class CcsStore
if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow)
{
attempt.State = CcsAttemptStates.FinalOrders;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.AddMinutes(2);
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
});
attempt.Revision++;
await SaveAsync(attempt);
}
@@ -206,9 +217,16 @@ public sealed class CcsStore
case "advance": Advance(attempt, definition, command.Minutes); break;
case "interval_history":
attempt.Actions.Add(Action("interval_history", "interval_history", attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." });
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
});
Advance(attempt, definition, 1);
break;
case "acknowledge_opening":
attempt.OpeningCompleted = true;
break;
case "end_case": EnterFinalOrders(attempt); break;
case "exit_case": await CompleteAsync(attempt); break;
}
@@ -220,6 +238,63 @@ public sealed class CcsStore
return attempt;
}
public async Task<CcsAttempt> ApplyBatchCommandAsync(CcsAttempt attempt, CcsBatchCommandRequest request)
{
var validation = CcsCaseValidator.ValidateBatch(request);
if (validation is not null) throw new ArgumentException(validation);
if (attempt.ProcessedCommandIds.Contains(request.CommandId, StringComparer.Ordinal)) return attempt;
if (attempt.Revision != request.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale.");
if (attempt.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned)
throw new InvalidOperationException("The CCS attempt is closed.");
if (attempt.State == CcsAttemptStates.FinalOrders && request.Commands.Any(value => value.Type is not ("order" or "cancel_order")))
throw new InvalidOperationException("Only final orders may be changed now.");
var definition = await DefinitionAsync(attempt);
var now = DateTime.UtcNow;
if (request.Commands.Any(value => value.Type is "exam" or "interval_history"))
{
var selected = request.Commands.Where(item => item.Type == "exam").Select(item =>
definition.Exams.FirstOrDefault(value => value.Code.Equals(item.Code, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException($"Unknown examination component: {item.Code}.")).ToList();
var includeHistory = request.Commands.Any(item => item.Type == "interval_history");
var startingMinute = attempt.SimulatedMinute;
foreach (var exam in selected)
attempt.Actions.Add(Action("exam", exam.Code, startingMinute, now));
if (includeHistory)
attempt.Actions.Add(Action("interval_history", "interval_history", startingMinute, now));
Advance(attempt, definition, selected.Sum(value => Math.Max(1, value.DurationMinutes)) + (includeHistory ? 1 : 0));
if (includeHistory)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
});
foreach (var exam in selected)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
});
}
else
{
foreach (var item in request.Commands)
{
if (item.Type == "order") await ApplyOrderAsync(attempt, definition, item.Code, now, false);
else CancelOrder(attempt, item.Code, now);
}
ProcessDue(attempt);
}
attempt.ProcessedCommandIds.Add(request.CommandId);
if (attempt.ProcessedCommandIds.Count > 100)
attempt.ProcessedCommandIds.RemoveRange(0, attempt.ProcessedCommandIds.Count - 100);
attempt.Revision++;
attempt.UpdatedUtc = now;
await SaveAsync(attempt);
return attempt;
}
public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null)
{
limit = Math.Clamp(limit, 1, 50);
@@ -314,19 +389,49 @@ public sealed class CcsStore
{
var definition = await DefinitionAsync(attempt);
var completed = attempt.State == CcsAttemptStates.Completed;
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
? []
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
var activeOrders = activeCatalog.Select(order =>
{
var orderedMinute = attempt.Actions.LastOrDefault(value =>
value.Type == "order" && value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase))?.SimulatedMinute ?? 0;
var pending = attempt.PendingResults.FirstOrDefault(value =>
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
var reported = attempt.Chart.LastOrDefault(value =>
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
&& value.Category is not CcsChartCategories.Orders);
return new CcsActiveOrder
{
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
IsPending = pending is not null, HasResult = reported is not null
};
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
return new CcsAttemptResponse
{
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location,
DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(),
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
{
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
}).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
@@ -342,7 +447,7 @@ public sealed class CcsStore
await SaveAsync(attempt);
}
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now, bool processImmediately = true)
{
code = code.Trim().ToLowerInvariant();
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
@@ -352,17 +457,30 @@ public sealed class CcsStore
if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code);
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
var delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes;
var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
attempt.PendingResults.Add(new CcsPendingResult { OrderCode = code, DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches });
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" });
if (delay == 0) ProcessDue(attempt);
attempt.PendingResults.Add(new CcsPendingResult
{
OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName,
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches
});
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
});
if (delay == 0 && processImmediately) ProcessDue(attempt);
}
private static void ApplyExam(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
{
var exam = definition.Exams.FirstOrDefault(value => value.Code.Equals(code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("Unknown examination component.");
attempt.Actions.Add(Action("exam", exam.Code, attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "exam", SimulatedMinute = attempt.SimulatedMinute, Text = $"{exam.Name}: {exam.Finding}" });
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
});
Advance(attempt, definition, Math.Max(1, exam.DurationMinutes));
}
@@ -371,7 +489,11 @@ public sealed class CcsStore
attempt.Actions.Add(Action("cancel_order", code, attempt.SimulatedMinute, now));
attempt.ActiveOrderCodes.RemoveAll(value => value.Equals(code, StringComparison.OrdinalIgnoreCase));
attempt.PendingResults.RemoveAll(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {code}" });
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = Humanize(code),
SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {Humanize(code)}"
});
}
private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now)
@@ -379,7 +501,11 @@ public sealed class CcsStore
if (!definition.AllowedLocations.Contains(location, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException("Unsupported location.");
attempt.Location = location;
attempt.Actions.Add(Action("location", location, attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {location}." });
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "location", Title = "Location change",
SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {Humanize(location)}."
});
}
private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes)
@@ -403,13 +529,20 @@ public sealed class CcsStore
private static void ProcessDue(CcsAttempt attempt)
{
var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList();
var vitalsChanged = false;
foreach (var result in due)
{
if (!string.IsNullOrWhiteSpace(result.Result))
attempt.Chart.Add(new CcsChartEntry { Category = "results", SimulatedMinute = attempt.SimulatedMinute, Text = result.Result });
attempt.Chart.Add(new CcsChartEntry
{
Category = string.IsNullOrWhiteSpace(result.Category) ? CcsChartCategories.OtherTest : result.Category,
Code = result.OrderCode, Title = result.Title, SimulatedMinute = attempt.SimulatedMinute, Text = result.Result
});
foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
vitalsChanged |= result.VitalPatches.Count > 0;
attempt.PendingResults.Remove(result);
}
if (vitalsChanged) SnapshotVitals(attempt);
}
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
@@ -419,13 +552,20 @@ public sealed class CcsStore
&& value.TriggerMinute <= attempt.SimulatedMinute
&& value.RequiresOrderCodes.All(activeOrders.Contains)
&& (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList();
var vitalsChanged = false;
foreach (var item in events)
{
attempt.ProcessedEventIds.Add(item.Id);
foreach (var patch in item.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = item.Message });
vitalsChanged |= item.VitalPatches.Count > 0;
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = item.Id, Title = "Clinical update",
SimulatedMinute = attempt.SimulatedMinute, Text = item.Message
});
if (item.EndCase) EnterFinalOrders(attempt);
}
if (vitalsChanged) SnapshotVitals(attempt);
return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase);
}
@@ -433,9 +573,21 @@ public sealed class CcsStore
{
if (attempt.State != CcsAttemptStates.Active) return;
attempt.State = CcsAttemptStates.FinalOrders;
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "Case-end instructions: finalize patient care orders." });
if (attempt.HardDeadlineUtc is not null)
{
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
}
else
{
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(
remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
}
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
});
}
private async Task CompleteAsync(CcsAttempt attempt)
@@ -489,12 +641,44 @@ public sealed class CcsStore
};
}
public static int ActivePhaseMinutes(int totalMinutes) => Math.Max(1, totalMinutes - 2);
public static DateTime FinalOrdersDeadline(DateTime now, DateTime? hardDeadline)
{
var proposed = now.AddMinutes(2);
return hardDeadline is null || proposed <= hardDeadline ? proposed : hardDeadline.Value;
}
public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
?? throw new InvalidOperationException("The case revision is unavailable.");
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt);
private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
private static void SnapshotVitals(CcsAttempt attempt)
{
if (attempt.VitalHistory.Count > 0
&& attempt.VitalHistory[^1].SimulatedMinute == attempt.SimulatedMinute
&& attempt.VitalHistory[^1].Values.OrderBy(value => value.Key).SequenceEqual(attempt.CurrentVitals.OrderBy(value => value.Key)))
return;
attempt.VitalHistory.Add(new CcsVitalSnapshot
{
SimulatedMinute = attempt.SimulatedMinute,
Values = new Dictionary<string, string>(attempt.CurrentVitals)
});
}
private static string ResultCategory(string? responseSection, string catalogSection, string catalogCategory)
{
var explicitSection = string.IsNullOrWhiteSpace(responseSection) ? catalogSection : responseSection;
if (!string.IsNullOrWhiteSpace(explicitSection)) return explicitSection;
return catalogCategory.ToLowerInvariant() switch
{
"laboratory" => CcsChartCategories.Lab,
"imaging" => CcsChartCategories.Imaging,
"medication" or "procedure" or "monitoring" => CcsChartCategories.Treatment,
_ => CcsChartCategories.OtherTest
};
}
private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..]));
private static string DescribeExpected(CcsScoringCriterion criterion, List<string> targets)
{
+28 -21
View File
@@ -1,25 +1,25 @@
{
"orders": [
{ "code": "iv_access", "canonicalName": "Peripheral intravenous access", "category": "procedure", "aliases": ["iv", "intravenous line"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "cardiac_monitor", "canonicalName": "Continuous cardiac monitoring", "category": "monitoring", "aliases": ["telemetry", "cardiac monitor"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "pulse_oximetry", "canonicalName": "Continuous pulse oximetry", "category": "monitoring", "aliases": ["pulse ox", "oxygen saturation"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "normal_saline_bolus", "canonicalName": "0.9% sodium chloride intravenous bolus", "category": "medication", "aliases": ["normal saline", "isotonic saline", "fluid bolus"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "balanced_crystalloid_bolus", "canonicalName": "Balanced crystalloid intravenous bolus", "category": "medication", "aliases": ["lactated ringers", "ringer lactate", "iv fluids"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "bmp", "canonicalName": "Basic metabolic panel", "category": "laboratory", "aliases": ["electrolytes", "chemistry", "potassium", "creatinine"], "defaultTurnaroundMinutes": 12, "availableLocations": ["emergency_department", "icu", "ward"] },
{ "code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "aliases": ["vbg", "venous ph"], "defaultTurnaroundMinutes": 8, "availableLocations": ["emergency_department", "icu"] },
{ "code": "beta_hydroxybutyrate", "canonicalName": "Serum beta-hydroxybutyrate", "category": "laboratory", "aliases": ["ketones", "serum ketones", "bohb"], "defaultTurnaroundMinutes": 15, "availableLocations": ["emergency_department", "icu", "ward"] },
{ "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count"], "defaultTurnaroundMinutes": 12, "availableLocations": [] },
{ "code": "urinalysis", "canonicalName": "Urinalysis", "category": "laboratory", "aliases": ["urine test", "ua"], "defaultTurnaroundMinutes": 15, "availableLocations": [] },
{ "code": "pregnancy_test", "canonicalName": "Serum beta-hCG pregnancy test", "category": "laboratory", "aliases": ["pregnancy test", "hcg"], "defaultTurnaroundMinutes": 15, "availableLocations": [] },
{ "code": "ecg", "canonicalName": "12-lead electrocardiogram", "category": "diagnostic", "aliases": ["ekg", "electrocardiogram"], "defaultTurnaroundMinutes": 10, "availableLocations": ["emergency_department", "icu", "ward"] },
{ "code": "insulin_infusion", "canonicalName": "Regular insulin intravenous infusion", "category": "medication", "aliases": ["insulin drip", "iv insulin", "regular insulin"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "potassium_chloride_iv", "canonicalName": "Potassium chloride added to intravenous fluid", "category": "medication", "aliases": ["potassium replacement", "kcl", "replace potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "dextrose_half_normal_saline", "canonicalName": "Dextrose 5% in 0.45% sodium chloride", "category": "medication", "aliases": ["d5 half normal", "dextrose fluid", "d5 0.45 saline"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "sodium_bicarbonate_iv", "canonicalName": "Intravenous sodium bicarbonate", "category": "medication", "aliases": ["bicarbonate", "bicarb"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "ceftriaxone", "canonicalName": "Intravenous ceftriaxone", "category": "medication", "aliases": ["rocephin", "antibiotic"], "defaultTurnaroundMinutes": 0, "availableLocations": [] },
{ "code": "serial_bmp", "canonicalName": "Basic metabolic panel every 4 hours", "category": "laboratory", "aliases": ["repeat electrolytes", "serial chemistry", "monitor potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "hourly_glucose", "canonicalName": "Capillary glucose every hour", "category": "monitoring", "aliases": ["hourly glucose", "fingerstick glucose", "poc glucose"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] },
{ "code": "icu_admission", "canonicalName": "Admit to intensive care unit", "category": "disposition", "aliases": ["icu", "intensive care admission"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department"] }
{ "code": "iv_access", "canonicalName": "Peripheral intravenous access", "category": "procedure", "aliases": ["iv", "intravenous line"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Peripheral IV", "displayFrequency": "Once", "resultSection": "treatment" },
{ "code": "cardiac_monitor", "canonicalName": "Continuous cardiac monitoring", "category": "monitoring", "aliases": ["telemetry", "cardiac monitor"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "pulse_oximetry", "canonicalName": "Continuous pulse oximetry", "category": "monitoring", "aliases": ["pulse ox", "oxygen saturation"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "normal_saline_bolus", "canonicalName": "0.9% sodium chloride intravenous bolus", "category": "medication", "aliases": ["normal saline", "isotonic saline", "fluid bolus"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Bolus", "resultSection": "treatment" },
{ "code": "balanced_crystalloid_bolus", "canonicalName": "Balanced crystalloid intravenous bolus", "category": "medication", "aliases": ["lactated ringers", "ringer lactate", "iv fluids"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Bolus", "resultSection": "treatment" },
{ "code": "bmp", "canonicalName": "Basic metabolic panel", "category": "laboratory", "aliases": ["electrolytes", "chemistry", "potassium", "creatinine"], "defaultTurnaroundMinutes": 12, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "aliases": ["vbg", "venous ph"], "defaultTurnaroundMinutes": 8, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Venous blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "beta_hydroxybutyrate", "canonicalName": "Serum beta-hydroxybutyrate", "category": "laboratory", "aliases": ["ketones", "serum ketones", "bohb"], "defaultTurnaroundMinutes": 15, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count"], "defaultTurnaroundMinutes": 12, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "urinalysis", "canonicalName": "Urinalysis", "category": "laboratory", "aliases": ["urine test", "ua"], "defaultTurnaroundMinutes": 15, "availableLocations": [], "displayRoute": "Urine", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "pregnancy_test", "canonicalName": "Serum beta-hCG pregnancy test", "category": "laboratory", "aliases": ["pregnancy test", "hcg"], "defaultTurnaroundMinutes": 15, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "ecg", "canonicalName": "12-lead electrocardiogram", "category": "diagnostic", "aliases": ["ekg", "electrocardiogram"], "defaultTurnaroundMinutes": 10, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "", "displayFrequency": "Once", "resultSection": "other_test" },
{ "code": "insulin_infusion", "canonicalName": "Regular insulin intravenous infusion", "category": "medication", "aliases": ["insulin drip", "iv insulin", "regular insulin"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "potassium_chloride_iv", "canonicalName": "Potassium chloride added to intravenous fluid", "category": "medication", "aliases": ["potassium replacement", "kcl", "replace potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "dextrose_half_normal_saline", "canonicalName": "Dextrose 5% in 0.45% sodium chloride", "category": "medication", "aliases": ["d5 half normal", "dextrose fluid", "d5 0.45 saline"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "sodium_bicarbonate_iv", "canonicalName": "Intravenous sodium bicarbonate", "category": "medication", "aliases": ["bicarbonate", "bicarb"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Once", "resultSection": "treatment" },
{ "code": "ceftriaxone", "canonicalName": "Intravenous ceftriaxone", "category": "medication", "aliases": ["rocephin", "antibiotic"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "IV", "displayFrequency": "Once", "resultSection": "treatment" },
{ "code": "serial_bmp", "canonicalName": "Basic metabolic panel every 4 hours", "category": "laboratory", "aliases": ["repeat electrolytes", "serial chemistry", "monitor potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Blood", "displayFrequency": "Every 4 hours", "resultSection": "lab" },
{ "code": "hourly_glucose", "canonicalName": "Capillary glucose every hour", "category": "monitoring", "aliases": ["hourly glucose", "fingerstick glucose", "poc glucose"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Capillary", "displayFrequency": "Every hour", "resultSection": "treatment" },
{ "code": "icu_admission", "canonicalName": "Admit to intensive care unit", "category": "disposition", "aliases": ["icu", "intensive care admission"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department"], "displayRoute": "", "displayFrequency": "Once", "resultSection": "treatment" }
],
"cases": [
{
@@ -36,7 +36,14 @@
"reasonForEncounter": "Two days of vomiting, abdominal discomfort, and progressive lethargy after stopping insulin when oral intake declined.",
"appearance": "She is lethargic but arousable. Mucous membranes are dry, and respirations are deep and rapid.",
"history": "She uses basal-bolus insulin and has no known kidney disease. She reports no dysuria, cough, diarrhea, alcohol use, or pregnancy symptoms. She has taken no insulin for approximately 36 hours.",
"vitals": { "temperature": "37.3 C (99.1 F)", "bloodPressure": "88/56 mm Hg", "pulse": "118/min", "respirations": "28/min", "oxygenSaturation": "98% on room air" },
"historySections": [
{ "code": "hpi", "title": "History of present illness", "text": "Two days of vomiting, abdominal discomfort, and progressive lethargy followed reduced oral intake. She stopped all insulin approximately 36 hours ago." },
{ "code": "past_medical_history", "title": "Past medical history", "text": "Type 1 diabetes mellitus treated with a basal-bolus insulin regimen. No known kidney disease." },
{ "code": "medications", "title": "Medications", "text": "Basal and mealtime insulin; no doses for approximately 36 hours." },
{ "code": "allergies", "title": "Allergies", "text": "No known drug allergies." },
{ "code": "review_of_systems", "title": "Review of systems", "text": "No dysuria, cough, diarrhea, alcohol use, or pregnancy symptoms." }
],
"vitals": { "temperature": "37.3 C (99.1 F)", "bloodPressure": "88/56 mm Hg", "pulse": "118/min", "respirations": "28/min", "oxygenSaturation": "98% on room air", "height": "165 cm (65 in)", "weight": "62 kg (137 lb)", "bmi": "22.8 kg/m²" },
"simulatedStartUtc": "2026-07-15T08:00:00Z",
"initialOrderCodes": []
},
+9 -4
View File
@@ -6,16 +6,21 @@ Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validate
## Case structure
- `initialChart` defines the opening history, appearance, vitals, location, simulated start, and standing orders.
- `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback.
- `allowedLocations` controls patient movement.
- `exams` defines selectable examination components and their simulated duration.
- `orderResponses` maps reusable catalog codes to case-specific results, delays, and vital-sign changes.
- Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions.
- `orderResponses` maps reusable catalog codes to case-specific results, delays, vital-sign changes, and an optional result-section override.
- `events` defines timed progression. `requiresOrderCodes` requires every listed order; `requiresAnyOrderCodes` supports equivalent alternatives.
- `scoringCriteria` awards or subtracts points for timing, sequence, and harmful care. `targetCodes` supports clinically equivalent actions; `afterTargetCode` enforces sequence.
- `references` stores the source title, publisher, HTTPS URL, supported claims, access date, and reviewer inside each case revision.
Order codes and event/criterion IDs must be stable lowercase identifiers. Case content should not expose the diagnosis in the opening chart or rely on unstructured text matching for scoring. Add aliases to the order catalog so users can find an order using common clinical wording.
## Scoring and prediction
Valid result sections are `lab`, `imaging`, `other_test`, and `treatment`; progress notes, vital snapshots, and the order sheet are generated by the runtime.
Practice attempts are scored for feedback but do not affect prediction. Only the first completed timed attempt for each distinct case contributes to the CCS estimate. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula.
## Timing, scoring, and prediction
New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it.
Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula.
+25 -3
View File
@@ -54,8 +54,8 @@ func load_cases() -> void:
_send("cases", HTTPClient.METHOD_GET, "/cases")
func create_attempt(case_external_id: String, mode: String) -> void:
_send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": mode})
func create_attempt(case_external_id: String, _mode: String = "timed") -> void:
_send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": "timed"})
func load_active() -> void:
@@ -77,6 +77,15 @@ func send_command(type: String, code: String = "", minutes: int = 0) -> void:
"minutes": minutes
})
func send_batch(commands: Array) -> void:
if attempt.is_empty() or commands.is_empty():
return
_send("attempt", HTTPClient.METHOD_POST, "/attempts/%s/commands/batch" % str(attempt.get("id", "")).uri_encode(), {
"commandId": "%d-%d" % [Time.get_ticks_usec(), randi()],
"expectedRevision": int(attempt.get("revision", 0)),
"commands": commands
})
func search_orders(query: String) -> void:
if query.strip_edges().length() < 2:
@@ -169,7 +178,20 @@ func abandon() -> void:
func deadline_unix() -> float:
var value := str(attempt.get("finalOrdersDeadlineUtc", attempt.get("deadlineUtc", "")))
var raw: Variant = attempt.get("finalOrdersDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("deadlineUtc", "")
var value := str(raw)
return 0.0 if value.is_empty() or value == "<null>" else float(Time.get_unix_time_from_datetime_string(value))
func hard_deadline_unix() -> float:
var raw: Variant = attempt.get("hardDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("finalOrdersDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("deadlineUtc", "")
var value := str(raw)
return 0.0 if value.is_empty() or value == "<null>" else float(Time.get_unix_time_from_datetime_string(value))
+1 -1
View File
@@ -398,7 +398,7 @@ func _case_cell(item: Dictionary, dark: bool) -> Control:
title.add_theme_color_override("font_color", Brand.text_color(dark))
column.add_child(title)
var mode := Label.new()
mode.text = "Timed case"
mode.text = "Legacy practice case" if str(item.get("mode", "timed")) == "practice" else "Timed case"
_style_muted_label(mode, dark, 12)
column.add_child(mode)
return column
+867 -577
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -905,7 +905,7 @@ func _toggle_app_menu() -> void:
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"]
if menu_exit_button.visible:
menu_exit_button.text = (
"Leave Case" if current_screen_name == "ccs_player"
"Abandon CCS Case" if current_screen_name == "ccs_player"
else "Exit Study Session" if current_screen_name == "flashcards_study"
else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation"
else "Exit Run")
+50 -53
View File
@@ -1,8 +1,5 @@
extends SceneTree
const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
var failures := 0
var ccs_service
@@ -13,8 +10,6 @@ func _initialize() -> void:
func _run() -> void:
ccs_service = root.get_node("CCSService")
var theme_service = root.get_node("UIThemeService")
var prior_dark_mode: bool = theme_service.is_dark_mode()
var prior_attempt: Dictionary = ccs_service.attempt.duplicate(true)
var prior_orders: Array = ccs_service.orders.duplicate(true)
var prior_scale: float = ccs_service.text_scale
@@ -33,58 +28,42 @@ func _run() -> void:
player._render()
await process_frame
_assert(player.heading.text == "CCS Case | Emergency Department", "CCS header should show the readable location without a case id")
_assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vital chips should use the clinical priority order")
_assert(player.vitals_flow.get_child_count() == 5, "every supplied vital should render as a chip")
_assert(player.search_button.icon != null, "Order Clerk search should use a bundled icon rather than a font glyph")
_assert(player.summary.get_meta("ccs_region", "") == "summary", "summary highlighting should use its own region")
_assert(player.history.get_meta("ccs_region", "") == "history", "history highlighting should use its own region")
_assert(player.appearance.get_meta("ccs_region", "") == "appearance", "appearance highlighting should use its own region")
_assert(player.chart.get_meta("ccs_region", "") == "chart", "chart highlighting should use its own region")
_assert(not player.workspace.vertical, "wide CCS screens should use chart and Order Clerk columns")
_assert(player.orders.get_child_count() == 1, "active orders should render in the Order Clerk")
var active_order_row := player.orders.get_child(0) as HBoxContainer
var active_order_label := active_order_row.get_child(0) as Label
_assert(active_order_label.text == "Normal Saline Bolus", "internal order codes should be human readable")
_assert(player.actions.get_child_count() == 2, "available physical examinations should render as actions")
_assert(player.locations.get_child_count() == 2, "allowed locations should render as move actions")
_assert(not player.clock.visible, "the normal active-case countdown should remain hidden")
_assert(player.heading.text == "CCS Case | Emergency Department", "header should show the readable location")
_assert(player.action_buttons.size() == 4, "CCS workspace should expose four persistent clinical tasks")
_assert(player.tabs.get_tab_count() == 7, "CCS chart should expose seven predictable tabs")
_assert(player.tabs.get_tab_title(0) == "Order Sheet", "the first chart tab should be the order sheet")
_assert(player.allotted_label.text.contains("18 minutes + 2 minutes"), "20-minute cases should reserve two final-order minutes")
_assert(player.active_orders.get_child_count() == 1, "active orders should render as structured rows")
_assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vitals should use clinical priority order")
_assert(player._chart_entries("lab").size() == 1, "categorized results should render in their assigned tab")
_assert(player._simulated_time_text(60).contains("10:00 AM"), "simulated time should derive from the case start")
_assert(not player.opening_active, "completed opening flows should not replay on resume")
_assert(not player.final_orders_notice.visible, "active cases should not show final-order instructions")
var clerk_font_size: int = player.search.get_theme_font_size("font_size")
var chart_font_size: int = player.summary.get_theme_font_size("normal_font_size")
var original_size: int = player.clinical_labels[0].get_theme_font_size("normal_font_size")
var prior_profile_api_url: String = ProjectSettings.get_setting("step3/profile_api_url", "")
ProjectSettings.set_setting("step3/profile_api_url", "http://127.0.0.1:1")
player._zoom(ccs_service.TEXT_SCALE_STEP)
ProjectSettings.set_setting("step3/profile_api_url", prior_profile_api_url)
_assert(player.summary.get_theme_font_size("normal_font_size") > chart_font_size, "text zoom should enlarge clinical chart text")
_assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "text zoom should not resize Order Clerk controls")
_assert(player.clinical_labels[0].get_theme_font_size("normal_font_size") > original_size, "clinical text zoom should remain available")
_assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "clinical text zoom should not resize order controls")
player._stage_order("bmp")
_assert(player.staged_order_codes == ["bmp"], "orders should stage before submission")
_assert(not player.commit_orders_button.disabled, "staged orders should enable confirmation")
player._clear_staged_orders()
_assert(player.commit_orders_button.disabled, "clearing staged orders should disable confirmation")
player.size = Vector2(800, 900)
player._update_responsive_layout()
_assert(player.workspace.vertical, "narrow CCS screens should stack the Order Clerk below the chart")
_assert(player.action_grid.columns == 2, "narrow CCS screens should use a two-column action workspace")
ccs_service.attempt.state = "final_orders"
ccs_service.attempt.finalOrdersDeadlineUtc = Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 90.0, true)
player._render()
_assert(player.final_orders_notice.visible and player.clock.visible, "final-orders mode should show instructions and its deadline")
_assert(not player.time_row.visible, "time advancement should be unavailable during final orders")
_assert(player.final_orders_notice.visible, "final-orders mode should show instructions")
_assert(player.exit_button.text == "Submit Final Orders", "final-orders mode should expose final submission")
_assert(player.action_buttons[0].disabled and not player.action_buttons[1].disabled, "only order review should remain available in final orders")
theme_service.set_dark_mode(true)
await process_frame
theme_service.set_dark_mode(false)
Style.refresh_tree(player, true)
await process_frame
var light_panel := player.chart_panel.get_theme_stylebox("panel") as StyleBoxFlat
_assert(light_panel.bg_color.is_equal_approx(Brand.content_card_box(false, false).bg_color), "CCS light styles should win after the shell palette translation")
_assert(player.summary.get_theme_color("default_color").is_equal_approx(Brand.text_color(false)), "CCS light chart text should remain readable")
theme_service.set_dark_mode(true)
Style.refresh_tree(player, false)
await process_frame
var dark_panel := player.chart_panel.get_theme_stylebox("panel") as StyleBoxFlat
_assert(dark_panel.bg_color.is_equal_approx(Brand.content_card_box(true, false).bg_color), "CCS dark styles should win after the shell palette translation")
_assert(player.summary.get_theme_color("default_color").is_equal_approx(Brand.text_color(true)), "CCS dark chart text should remain readable")
theme_service.set_dark_mode(prior_dark_mode)
await process_frame
host.queue_free()
await process_frame
ccs_service.attempt = prior_attempt
@@ -94,14 +73,24 @@ func _run() -> void:
func _sample_attempt() -> Dictionary:
var now := Time.get_unix_time_from_system()
return {
"id": "attempt-1",
"state": "active",
"revision": 1,
"openingCompleted": true,
"location": "emergency_department",
"patientSummary": "24-year-old woman with type 1 diabetes mellitus",
"reasonForEncounter": "Two days of vomiting and progressive lethargy.",
"history": "She stopped insulin when oral intake declined.",
"historySections": [{"code": "hpi", "title": "History of present illness", "text": "She stopped insulin when oral intake declined."}],
"appearance": "She is lethargic but arousable.",
"simulatedStartUtc": "2026-07-15T09:00:00Z",
"simulatedMinute": 0,
"realTimeMinutes": 20,
"createdUtc": Time.get_datetime_string_from_unix_time(now - 60, true),
"deadlineUtc": Time.get_datetime_string_from_unix_time(now + 1020, true),
"hardDeadlineUtc": Time.get_datetime_string_from_unix_time(now + 1140, true),
"vitals": {
"oxygenSaturation": "98% on room air",
"pulse": "118/min",
@@ -109,14 +98,22 @@ func _sample_attempt() -> Dictionary:
"respirations": "28/min",
"bloodPressure": "88/56 mm Hg"
},
"chart": [{"simulatedMinute": 0, "category": "progress", "text": "Initial evaluation is in progress."}],
"activeOrderCodes": ["normal_saline_bolus"],
"availableExams": [
{"code": "general", "name": "General and hydration examination"},
{"code": "neurologic", "name": "Neurologic examination"}
"vitalHistory": [{"simulatedMinute": 0, "values": {"pulse": "118/min"}}],
"chart": [
{"simulatedMinute": 0, "category": "progress", "code": "reason", "title": "Reason for encounter", "text": "Initial evaluation."},
{"simulatedMinute": 12, "category": "lab", "code": "bmp", "title": "Basic metabolic panel", "text": "Glucose 580 mg/dL."}
],
"allowedLocations": ["emergency_department", "icu"],
"deadlineUtc": Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 600.0, true)
"activeOrderCodes": ["normal_saline_bolus"],
"activeOrders": [{
"code": "normal_saline_bolus", "name": "0.9% sodium chloride intravenous bolus",
"category": "medication", "route": "IV", "frequency": "Once",
"orderedMinute": 0, "reportMinute": 0
}],
"availableExams": [
{"code": "general", "name": "General and hydration examination", "durationMinutes": 1},
{"code": "neurologic", "name": "Neurologic examination", "durationMinutes": 1}
],
"allowedLocations": ["emergency_department", "icu"]
}
+28 -3
View File
@@ -161,15 +161,40 @@ test("public API and disposable learner journey", async () => {
expect(Array.isArray(cases)).toBeTruthy();
expect(cases.length).toBeGreaterThan(0);
const { data: attempt } = await call("/api/ccs/attempts", {
method: "POST", token, body: { caseExternalId: "", mode: "practice" }
method: "POST", token, body: { caseExternalId: "", mode: "timed" }
});
activeCcsAttempt = attempt.id;
expect(activeCcsAttempt).toBeTruthy();
expect(attempt.caseExternalId).toBeTruthy();
expect(attempt.state).toBe("active");
await call(`/api/ccs/attempts/${activeCcsAttempt}/abandon`, {
method: "POST", token, expected: [204]
const { data: opened } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, {
method: "POST", token, body: {
commandId: `smoke-opening-${suffix}`, expectedRevision: attempt.revision,
type: "acknowledge_opening", code: "", minutes: 0
}
});
expect(opened.openingCompleted).toBe(true);
const exam = opened.availableExams[0];
const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, {
method: "POST", token, body: {
commandId: `smoke-exam-${suffix}`, expectedRevision: opened.revision,
commands: [{ type: "exam", code: exam.code, minutes: 0 }]
}
});
const { data: finalOrders } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, {
method: "POST", token, body: {
commandId: `smoke-final-${suffix}`, expectedRevision: examined.revision,
type: "end_case", code: "", minutes: 0
}
});
expect(finalOrders.state).toBe("final_orders");
const { data: completedCcs } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, {
method: "POST", token, body: {
commandId: `smoke-complete-${suffix}`, expectedRevision: finalOrders.revision,
type: "exit_case", code: "", minutes: 0
}
});
expect(completedCcs.state).toBe("completed");
activeCcsAttempt = "";
// Flashcards: create a deck and card, study and grade it, verify it drops out of the due