Add interactive CCS case practice

This commit is contained in:
hz
2026-07-15 10:17:53 -05:00
parent d73fd5f2cf
commit 7256f93002
23 changed files with 1429 additions and 4 deletions
+87
View File
@@ -0,0 +1,87 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Text.Json;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class CcsCaseTests
{
[Fact]
public void DkaImportArtifactMatchesTheCaseSchema()
{
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/dka-emergency-001.json"));
var request = JsonSerializer.Deserialize<CcsImportRequest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(request);
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12);
}
[Fact]
public void PublishedCaseRequiresReferencesAndKnownOrders()
{
var request = ValidImport();
request.Cases[0].References = [];
request.Cases[0].OrderResponses.Add(new CcsCaseOrderDefinition { OrderCode = "unknown" });
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("reference", StringComparison.OrdinalIgnoreCase));
Assert.Contains(errors, value => value.Contains("unknown order code unknown"));
}
[Fact]
public void ScoringCreditsTimelySequencedCareAndPenalizesHarm()
{
var definition = ValidImport().Cases[0];
definition.ScoringCriteria =
[
new() { Id = "lab", ActionType = "order", TargetCode = "bmp", Points = 5, LatestMinute = 10, Rationale = "Check potassium." },
new() { Id = "insulin", ActionType = "order", TargetCode = "insulin", AfterTargetCode = "bmp", Points = 10, Rationale = "Start after potassium assessment." },
new() { Id = "bicarb", ActionType = "order", TargetCode = "bicarb", Points = -4, Rationale = "Avoid routine bicarbonate." }
];
var attempt = new CcsAttempt { Actions =
[
new() { Type = "order", Code = "bmp", SimulatedMinute = 2 },
new() { Type = "order", Code = "insulin", SimulatedMinute = 4 },
new() { Type = "order", Code = "bicarb", SimulatedMinute = 5 }
]};
var score = CcsStore.CalculateScore(attempt, definition);
Assert.Equal(11, score.EarnedPoints);
Assert.Equal(15, score.PossiblePoints);
Assert.Equal(73.3, score.Percent);
Assert.Contains(score.Criteria, value => value.CriterionId == "bicarb" && value.Status == "harmful");
}
[Fact]
public void ValidVersionedCasePassesValidation()
{
Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>()));
}
private static CcsImportRequest ValidImport() => new()
{
Orders =
[
new() { Code = "bmp", CanonicalName = "Basic metabolic panel", Category = "laboratory" },
new() { Code = "insulin", CanonicalName = "Insulin infusion", Category = "medication" },
new() { Code = "bicarb", CanonicalName = "Sodium bicarbonate", Category = "medication" }
],
Cases =
[
new()
{
ExternalId = "ccs-test-dka-001", Title = "Diabetic ketoacidosis", System = "Endocrine System",
PresentingLocation = "emergency_department", AllowedLocations = ["emergency_department", "icu"],
IsPublished = true, RealTimeMinutes = 20, Difficulty = 2,
InitialChart = new() { PatientSummary = "Adult with diabetes", ReasonForEncounter = "Vomiting" },
OrderResponses = [new() { OrderCode = "bmp", Result = "Potassium 4.9 mEq/L" }],
ScoringCriteria = [new() { Id = "lab", ActionType = "order", TargetCode = "bmp", Points = 5 }],
References = [new() { Title = "Consensus report", Publisher = "Example Society", Url = "https://example.org/consensus", Supports = "Case management and scoring", AccessedOn = "2026-07-15", Reviewer = "Question bank editorial review" }]
}
]
};
}
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/ccs")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CcsController(CcsStore store) : ControllerBase
{
[HttpGet("cases")]
public async Task<IActionResult> Cases() => Ok(await store.ListCasesAsync());
[HttpGet("orders/search")]
public async Task<IActionResult> SearchOrders([FromQuery] string q, [FromQuery] int limit = 20) =>
Ok(await store.SearchOrdersAsync(q ?? "", limit));
[HttpPost("attempts")]
public async Task<IActionResult> CreateAttempt(CreateCcsAttemptRequest request)
{
try { return Ok(await store.ToResponseAsync(await store.CreateAttemptAsync(UserId(), request))); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpGet("attempts/{id}")]
public async Task<IActionResult> Attempt(string id)
{
var attempt = await store.GetOwnedAsync(id, UserId());
return attempt is null ? NotFound() : Ok(await store.ToResponseAsync(attempt));
}
[HttpPost("attempts/{id}/commands")]
public async Task<IActionResult> Command(string id, CcsCommandRequest request)
{
var attempt = await store.GetOwnedAsync(id, UserId());
if (attempt is null) return NotFound();
try { return Ok(await store.ToResponseAsync(await store.ApplyCommandAsync(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)
{
var attempt = await store.GetOwnedAsync(id, UserId());
if (attempt is null) return NotFound();
await store.AbandonAsync(attempt);
return NoContent();
}
[HttpGet("history")]
public async Task<IActionResult> History([FromQuery] int limit = 50) => Ok(await store.HistoryAsync(UserId(), limit));
[HttpGet("prediction")]
public async Task<IActionResult> Prediction() => Ok(await store.PredictionAsync(UserId()));
[HttpPost("admin/import")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Import(CcsImportRequest request)
{
var response = await store.ImportAsync(request);
return response.Errors.Count > 0 ? BadRequest(response) : Ok(response);
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
+258
View File
@@ -0,0 +1,258 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CcsAttemptStates
{
public const string Active = "active";
public const string FinalOrders = "final_orders";
public const string Completed = "completed";
public const string Abandoned = "abandoned";
}
public static class CcsAttemptModes
{
public const string Practice = "practice";
public const string Timed = "timed";
}
public class CcsOrderDefinition
{
[BsonId] public string Code { get; set; } = "";
public string CanonicalName { get; set; } = "";
public string Category { get; set; } = "";
public List<string> Aliases { get; set; } = [];
public int DefaultTurnaroundMinutes { get; set; }
public List<string> AvailableLocations { get; set; } = [];
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public class CcsCaseRevision
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string ExternalId { get; set; } = "";
public int Version { get; set; } = 1;
public bool IsCurrent { get; set; } = true;
public bool IsPublished { get; set; }
public string Title { get; set; } = "";
public string System { get; set; } = "";
public string PresentingLocation { get; set; } = "";
public int RealTimeMinutes { get; set; } = 20;
public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = [];
public CcsInitialChart InitialChart { get; set; } = new();
public List<string> AllowedLocations { get; set; } = [];
public List<CcsExamDefinition> Exams { get; set; } = [];
public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = [];
public List<CcsCaseEvent> Events { get; set; } = [];
public List<CcsScoringCriterion> ScoringCriteria { get; set; } = [];
public List<QuestionReference> References { get; set; } = [];
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public class CcsInitialChart
{
public string PatientSummary { get; set; } = "";
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public Dictionary<string, string> Vitals { get; set; } = [];
public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow;
public List<string> InitialOrderCodes { get; set; } = [];
}
public class CcsExamDefinition
{
public string Code { get; set; } = "";
public string Name { get; set; } = "";
public int DurationMinutes { get; set; } = 1;
public string Finding { get; set; } = "";
}
public class CcsCaseOrderDefinition
{
public string OrderCode { get; set; } = "";
public int? TurnaroundMinutes { get; set; }
public string Result { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
}
public class CcsCaseEvent
{
public string Id { get; set; } = "";
public int TriggerMinute { get; set; }
public List<string> RequiresOrderCodes { get; set; } = [];
public List<string> RequiresAnyOrderCodes { get; set; } = [];
public string Message { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
public bool EndCase { get; set; }
}
public class CcsScoringCriterion
{
public string Id { get; set; } = "";
public string ActionType { get; set; } = "order";
public string TargetCode { get; set; } = "";
public List<string> TargetCodes { get; set; } = [];
public int Points { get; set; }
public int? EarliestMinute { get; set; }
public int? LatestMinute { get; set; }
public string AfterTargetCode { get; set; } = "";
public string Rationale { get; set; } = "";
}
public class CcsAttempt
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
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 State { get; set; } = CcsAttemptStates.Active;
public int Revision { get; set; }
public int SimulatedMinute { 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<string> ActiveOrderCodes { get; set; } = [];
public List<string> ProcessedEventIds { get; set; } = [];
public List<string> ProcessedCommandIds { get; set; } = [];
public DateTime? DeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? CompletedUtc { get; set; }
public CcsCaseScore? Score { get; set; }
}
public class CcsAttemptAction
{
public string Type { get; set; } = "";
public string Code { get; set; } = "";
public int SimulatedMinute { get; set; }
public DateTime RealUtc { get; set; } = DateTime.UtcNow;
}
public class CcsPendingResult
{
public string OrderCode { get; set; } = "";
public int DueMinute { get; set; }
public string Result { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
}
public class CcsChartEntry
{
public string Category { get; set; } = "progress";
public int SimulatedMinute { get; set; }
public string Text { get; set; } = "";
}
public class CcsCaseScore
{
public double Percent { get; set; }
public int EarnedPoints { get; set; }
public int PossiblePoints { get; set; }
public List<CcsCriterionResult> Criteria { get; set; } = [];
}
public class CcsCriterionResult
{
public string CriterionId { get; set; } = "";
public string Status { get; set; } = "missed";
public int Points { get; set; }
public string Rationale { get; set; } = "";
}
public class CcsImportRequest
{
public List<CcsOrderDefinition> Orders { get; set; } = [];
public List<CcsCaseRevision> Cases { get; set; } = [];
public bool ValidateOnly { get; set; }
}
public class CcsImportResponse
{
public int OrdersUpserted { get; set; }
public int CasesCreated { get; set; }
public List<string> Errors { get; set; } = [];
}
public class CreateCcsAttemptRequest
{
public string CaseExternalId { get; set; } = "";
public string Mode { get; set; } = CcsAttemptModes.Practice;
}
public class CcsCommandRequest
{
public string CommandId { get; set; } = "";
public int ExpectedRevision { get; set; }
public string Type { get; set; } = "";
public string Code { get; set; } = "";
public int Minutes { get; set; }
}
public class CcsCaseSummary
{
public string ExternalId { get; set; } = "";
public string Title { get; set; } = "";
public string System { get; set; } = "";
public string PresentingLocation { get; set; } = "";
public int RealTimeMinutes { get; set; }
public int Difficulty { get; set; }
public List<string> Tags { get; set; } = [];
}
public class CcsAttemptResponse
{
public string Id { get; set; } = "";
public string CaseExternalId { get; set; } = "";
public string CaseTitle { get; set; } = "";
public string Mode { get; set; } = "";
public string State { get; set; } = "";
public int Revision { get; set; }
public int SimulatedMinute { get; set; }
public string Location { get; set; } = "";
public DateTime? DeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; }
public string PatientSummary { get; set; } = "";
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public Dictionary<string, string> Vitals { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = [];
public CcsCaseScore? Score { get; set; }
public List<QuestionReference> References { get; set; } = [];
}
public class CcsHistoryItem
{
public string Id { get; set; } = "";
public string CaseExternalId { get; set; } = "";
public string CaseTitle { get; set; } = "";
public string Mode { get; set; } = "";
public string State { get; set; } = "";
public double? Score { get; set; }
public DateTime CreatedUtc { get; set; }
}
public class CcsPredictionResponse
{
public double? CcsScore { get; set; }
public int ContributingCases { get; set; }
public int? McqPredictedScore { get; set; }
public int? CombinedPredictedScore { get; set; }
public int? RangeLow { get; set; }
public int? RangeHigh { get; set; }
public string ModelVersion { get; set; } = "combined-ccs-1";
public bool IsProvisional { get; set; } = true;
}
+1
View File
@@ -17,6 +17,7 @@ builder.Services.AddSingleton<RatingStore>();
builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddSingleton<QuestionReportStore>();
builder.Services.AddSingleton<ExamSimulationStore>();
builder.Services.AddSingleton<CcsStore>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
@@ -0,0 +1,90 @@
using QuestionsApi.Models;
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"];
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{
var errors = new List<string>();
var orderCodes = existingOrderCodes.Concat(request.Orders.Select(order => order.Code.Trim())).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var order in request.Orders)
{
var prefix = $"order {order.Code}: ";
if (string.IsNullOrWhiteSpace(order.Code)) errors.Add(prefix + "code required");
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 (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count)
errors.Add("order codes must be unique within an import");
if (request.Cases.Select(item => item.ExternalId).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Cases.Count)
errors.Add("case externalIds must be unique within an import");
foreach (var item in request.Cases)
{
var prefix = $"case {item.ExternalId}: ";
if (string.IsNullOrWhiteSpace(item.ExternalId)) errors.Add(prefix + "externalId required");
if (string.IsNullOrWhiteSpace(item.Title)) errors.Add(prefix + "title required");
if (string.IsNullOrWhiteSpace(item.System)) errors.Add(prefix + "system required");
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "allowedLocations must include presentingLocation");
if (item.IsPublished)
{
var referenceError = QuestionValidator.ValidateReferences(item.References, true);
if (referenceError is not null) errors.Add(prefix + referenceError);
if (item.ScoringCriteria.Count == 0) errors.Add(prefix + "published cases require scoringCriteria");
}
var examCodes = item.Exams.Select(exam => exam.Code).ToList();
if (examCodes.Any(string.IsNullOrWhiteSpace) || examCodes.Distinct(StringComparer.OrdinalIgnoreCase).Count() != examCodes.Count)
errors.Add(prefix + "exam codes must be present and unique");
var eventIds = item.Events.Select(value => value.Id).ToList();
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}");
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();
if (criteriaIds.Any(string.IsNullOrWhiteSpace) || criteriaIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != criteriaIds.Count)
errors.Add(prefix + "scoring criterion ids must be present and unique");
foreach (var criterion in item.ScoringCriteria)
{
var targets = criterion.TargetCodes.Count > 0 ? criterion.TargetCodes : [criterion.TargetCode];
if (criterion.Points == 0) errors.Add(prefix + $"criterion {criterion.Id} points cannot be zero");
if (targets.Any(string.IsNullOrWhiteSpace)) errors.Add(prefix + $"criterion {criterion.Id} requires a target code");
foreach (var target in targets)
{
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
}
}
}
return errors;
}
public static string? ValidateCreate(CreateCcsAttemptRequest request)
{
if (!Modes.Contains(request.Mode)) return "mode must be practice or timed";
return null;
}
public static string? ValidateCommand(CcsCommandRequest request)
{
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (!CommandTypes.Contains(request.Type)) return "unsupported command type";
if ((request.Type is "exam" or "order" or "cancel_order" or "location") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
return null;
}
}
+401
View File
@@ -0,0 +1,401 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CcsStore
{
private readonly IMongoCollection<CcsCaseRevision> _cases;
private readonly IMongoCollection<CcsOrderDefinition> _orders;
private readonly IMongoCollection<CcsAttempt> _attempts;
private readonly ExamSimulationStore _examSimulations;
public CcsStore(MongoDatabaseProvider mongo, ExamSimulationStore examSimulations)
{
_cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
_orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
_attempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
_examSimulations = examSimulations;
_cases.Indexes.CreateOne(new CreateIndexModel<CcsCaseRevision>(
Builders<CcsCaseRevision>.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version),
new CreateIndexOptions { Unique = true }));
_cases.Indexes.CreateOne(new CreateIndexModel<CcsCaseRevision>(
Builders<CcsCaseRevision>.IndexKeys.Ascending(value => value.IsCurrent).Ascending(value => value.IsPublished)));
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State).Descending(value => value.UpdatedUtc)));
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc)));
}
public async Task<CcsImportResponse> ImportAsync(CcsImportRequest request)
{
var existingCodes = (await _orders.Find(Builders<CcsOrderDefinition>.Filter.Empty).Project(value => value.Code).ToListAsync())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var errors = CcsCaseValidator.ValidateImport(request, existingCodes);
if (errors.Count > 0 || request.ValidateOnly) return new CcsImportResponse { Errors = errors };
foreach (var order in request.Orders)
{
order.Code = order.Code.Trim().ToLowerInvariant();
order.UpdatedUtc = DateTime.UtcNow;
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
foreach (var incoming in request.Cases)
{
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null)
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
incoming.Version = (current?.Version ?? 0) + 1;
incoming.IsCurrent = true;
incoming.CreatedUtc = DateTime.UtcNow;
await _cases.InsertOneAsync(incoming);
created++;
}
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
}
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
(await _cases.Find(value => value.IsCurrent && value.IsPublished).SortBy(value => value.Title).ToListAsync())
.Select(ToSummary).ToList();
public async Task<List<CcsOrderDefinition>> SearchOrdersAsync(string query, int limit)
{
query = query.Trim().ToLowerInvariant();
if (query.Length < 2) return [];
var all = await _orders.Find(Builders<CcsOrderDefinition>.Filter.Empty).ToListAsync();
return all.Select(order => new
{
Order = order,
Rank = Rank(order, query)
})
.Where(value => value.Rank < 1000)
.OrderBy(value => value.Rank)
.ThenBy(value => value.Order.CanonicalName)
.Take(Math.Clamp(limit, 1, 30))
.Select(value => value.Order).ToList();
}
public async Task<CcsAttempt> CreateAttemptAsync(string userId, CreateCcsAttemptRequest request)
{
var validation = CcsCaseValidator.ValidateCreate(request);
if (validation is not null) throw new ArgumentException(validation);
CcsCaseRevision? definition;
if (string.IsNullOrWhiteSpace(request.CaseExternalId))
{
var cases = await _cases.Find(value => value.IsCurrent && value.IsPublished).ToListAsync();
if (cases.Count == 0) throw new InvalidOperationException("No published CCS cases are available.");
var attempted = (await _attempts.Find(value => value.UserId == userId).Project(value => value.CaseExternalId).ToListAsync()).ToHashSet();
definition = cases.Where(value => !attempted.Contains(value.ExternalId)).OrderBy(_ => Random.Shared.Next()).FirstOrDefault()
?? cases.OrderBy(_ => Random.Shared.Next()).First();
}
else
{
definition = await _cases.Find(value => value.ExternalId == request.CaseExternalId && value.IsCurrent && value.IsPublished).FirstOrDefaultAsync();
}
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
var now = DateTime.UtcNow;
var attempt = new CcsAttempt
{
UserId = userId,
CaseExternalId = definition.ExternalId,
CaseRevisionId = definition.Id ?? "",
CaseVersion = definition.Version,
Mode = request.Mode,
Location = definition.PresentingLocation,
CurrentVitals = 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,
Chart =
[
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter },
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance }
]
};
await _attempts.InsertOneAsync(attempt);
return attempt;
}
public async Task<CcsAttempt?> GetOwnedAsync(string id, string userId)
{
var attempt = await _attempts.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
if (attempt is null) return null;
if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow)
{
attempt.State = CcsAttemptStates.FinalOrders;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.AddMinutes(2);
attempt.Revision++;
await SaveAsync(attempt);
}
if (attempt.State == CcsAttemptStates.FinalOrders && attempt.FinalOrdersDeadlineUtc <= DateTime.UtcNow)
{
await CompleteAsync(attempt);
}
return attempt;
}
public async Task<CcsAttempt> ApplyCommandAsync(CcsAttempt attempt, CcsCommandRequest command)
{
var validation = CcsCaseValidator.ValidateCommand(command);
if (validation is not null) throw new ArgumentException(validation);
if (attempt.ProcessedCommandIds.Contains(command.CommandId, StringComparer.Ordinal)) return attempt;
if (attempt.Revision != command.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale.");
if (attempt.State == CcsAttemptStates.Completed || attempt.State == CcsAttemptStates.Abandoned)
throw new InvalidOperationException("The CCS attempt is closed.");
if (attempt.State == CcsAttemptStates.FinalOrders && command.Type is not ("order" or "cancel_order" or "exit_case"))
throw new InvalidOperationException("Only final orders may be changed now.");
var definition = await DefinitionAsync(attempt);
var now = DateTime.UtcNow;
switch (command.Type)
{
case "exam": ApplyExam(attempt, definition, command.Code, now); break;
case "order": await ApplyOrderAsync(attempt, definition, command.Code, now); break;
case "cancel_order": CancelOrder(attempt, command.Code, now); break;
case "location": ChangeLocation(attempt, definition, command.Code, now); break;
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." });
Advance(attempt, definition, 1);
break;
case "end_case": EnterFinalOrders(attempt); break;
case "exit_case": await CompleteAsync(attempt); break;
}
attempt.ProcessedCommandIds.Add(command.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<List<CcsHistoryItem>> HistoryAsync(string userId, int limit)
{
var attempts = await _attempts.Find(value => value.UserId == userId).SortByDescending(value => value.CreatedUtc).Limit(Math.Clamp(limit, 1, 100)).ToListAsync();
var revisionIds = attempts.Select(value => value.CaseRevisionId).Distinct().ToList();
var definitions = (await _cases.Find(value => revisionIds.Contains(value.Id!)).ToListAsync()).ToDictionary(value => value.Id ?? "");
return attempts.Select(value => new CcsHistoryItem
{
Id = value.Id ?? "", CaseExternalId = value.CaseExternalId,
CaseTitle = definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId,
Mode = value.Mode, State = value.State, Score = value.Score?.Percent, CreatedUtc = value.CreatedUtc
}).ToList();
}
public async Task<CcsPredictionResponse> PredictionAsync(string userId)
{
var timed = await _attempts.Find(value => value.UserId == userId && value.Mode == CcsAttemptModes.Timed && value.State == CcsAttemptStates.Completed && value.Score != null)
.SortBy(value => value.CreatedUtc).ToListAsync();
var firstByCase = timed.GroupBy(value => value.CaseExternalId).Select(group => group.First()).ToList();
double? ccs = firstByCase.Count == 0 ? null : Math.Round((140.0 + firstByCase.Sum(value => value.Score!.Percent)) / (2.0 + firstByCase.Count), 1);
var mcq = await _examSimulations.GetLatestCompletedScoreAsync(userId);
int? combined = null, low = null, high = null;
if (ccs is not null && mcq is not null)
{
var equivalent = Math.Clamp(227.0 + ccs.Value - 70.0, 1.0, 300.0);
combined = Math.Clamp((int)Math.Round(mcq.PredictedScore * 0.75 + equivalent * 0.25), 1, 300);
var ccsUncertainty = 20.0 / Math.Sqrt(firstByCase.Count);
var mcqUncertainty = Math.Max(10.0, (mcq.RangeHigh - mcq.RangeLow) / 2.0);
var range = (int)Math.Ceiling(Math.Sqrt(Math.Pow(mcqUncertainty * 0.75, 2) + Math.Pow(ccsUncertainty * 0.25, 2) + 100.0));
low = Math.Clamp(combined.Value - range, 1, 300);
high = Math.Clamp(combined.Value + range, 1, 300);
}
return new CcsPredictionResponse
{
CcsScore = ccs, ContributingCases = firstByCase.Count,
McqPredictedScore = mcq?.PredictedScore, CombinedPredictedScore = combined, RangeLow = low, RangeHigh = high
};
}
public async Task<CcsAttemptResponse> ToResponseAsync(CcsAttempt attempt)
{
var definition = await DefinitionAsync(attempt);
var completed = attempt.State == CcsAttemptStates.Completed;
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,
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Score = completed ? attempt.Score : null, References = completed ? definition.References : []
};
}
public async Task AbandonAsync(CcsAttempt attempt)
{
attempt.State = CcsAttemptStates.Abandoned;
attempt.UpdatedUtc = DateTime.UtcNow;
await SaveAsync(attempt);
}
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
{
code = code.Trim().ToLowerInvariant();
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
if (catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException("That order is unavailable at the current location.");
attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now));
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;
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);
}
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}" });
Advance(attempt, definition, Math.Max(1, exam.DurationMinutes));
}
private static void CancelOrder(CcsAttempt attempt, string code, DateTime now)
{
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}" });
}
private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now)
{
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}." });
}
private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes)
{
var target = attempt.SimulatedMinute + minutes;
while (attempt.SimulatedMinute < target && attempt.State == CcsAttemptStates.Active)
{
var next = new[]
{
target,
attempt.PendingResults.Where(value => value.DueMinute > attempt.SimulatedMinute).Select(value => value.DueMinute).DefaultIfEmpty(target).Min(),
definition.Events.Where(value => !attempt.ProcessedEventIds.Contains(value.Id) && value.TriggerMinute > attempt.SimulatedMinute).Select(value => value.TriggerMinute).DefaultIfEmpty(target).Min()
}.Min();
attempt.SimulatedMinute = next;
ProcessDue(attempt);
var interrupted = ProcessEvents(attempt, definition);
if (interrupted) break;
}
}
private static void ProcessDue(CcsAttempt attempt)
{
var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList();
foreach (var result in due)
{
if (!string.IsNullOrWhiteSpace(result.Result))
attempt.Chart.Add(new CcsChartEntry { Category = "results", SimulatedMinute = attempt.SimulatedMinute, Text = result.Result });
foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
attempt.PendingResults.Remove(result);
}
}
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
{
var activeOrders = attempt.Actions.Where(value => value.Type == "order").Select(value => value.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
var events = definition.Events.Where(value => !attempt.ProcessedEventIds.Contains(value.Id)
&& value.TriggerMinute <= attempt.SimulatedMinute
&& value.RequiresOrderCodes.All(activeOrders.Contains)
&& (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList();
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 });
if (item.EndCase) EnterFinalOrders(attempt);
}
return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase);
}
private static void EnterFinalOrders(CcsAttempt attempt)
{
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." });
}
private async Task CompleteAsync(CcsAttempt attempt)
{
if (attempt.State == CcsAttemptStates.Completed) return;
var definition = await DefinitionAsync(attempt);
attempt.State = CcsAttemptStates.Completed;
attempt.CompletedUtc = DateTime.UtcNow;
attempt.Score = CalculateScore(attempt, definition);
attempt.UpdatedUtc = DateTime.UtcNow;
await SaveAsync(attempt);
}
public static CcsCaseScore CalculateScore(CcsAttempt attempt, CcsCaseRevision definition)
{
var results = new List<CcsCriterionResult>();
var possible = definition.ScoringCriteria.Where(value => value.Points > 0).Sum(value => value.Points);
var earned = 0;
foreach (var criterion in definition.ScoringCriteria)
{
var targets = criterion.TargetCodes.Count > 0 ? criterion.TargetCodes : [criterion.TargetCode];
var action = attempt.Actions.FirstOrDefault(value => value.Type == criterion.ActionType && targets.Contains(value.Code, StringComparer.OrdinalIgnoreCase));
var met = action is not null
&& (criterion.EarliestMinute is null || action.SimulatedMinute >= criterion.EarliestMinute)
&& (criterion.LatestMinute is null || action.SimulatedMinute <= criterion.LatestMinute);
if (met && !string.IsNullOrWhiteSpace(criterion.AfterTargetCode))
{
var prior = attempt.Actions.FirstOrDefault(value => value.Code.Equals(criterion.AfterTargetCode, StringComparison.OrdinalIgnoreCase));
met = prior is not null && prior.SimulatedMinute <= action!.SimulatedMinute;
}
var awarded = met ? criterion.Points : 0;
earned += awarded;
results.Add(new CcsCriterionResult
{
CriterionId = criterion.Id,
Status = met ? criterion.Points < 0 ? "harmful" : "credited" : criterion.Points > 0 ? "missed" : "avoided",
Points = awarded, Rationale = criterion.Rationale
});
}
return new CcsCaseScore
{
EarnedPoints = earned, PossiblePoints = possible,
Percent = possible == 0 ? 0 : Math.Round(Math.Clamp(earned * 100.0 / possible, 0, 100), 1), Criteria = results
};
}
private 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 CcsCaseSummary ToSummary(CcsCaseRevision value) => new()
{
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags
};
private static int Rank(CcsOrderDefinition order, string query)
{
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
if (values.Any(value => value == query)) return 0;
if (values.Any(value => value.StartsWith(query))) return 1;
if (values.Any(value => value.Contains(query))) return 2;
return 1000;
}
}
@@ -161,6 +161,10 @@ public class ExamSimulationStore
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
.SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync();
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) =>
(await _simulations.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
.SortByDescending(item => item.CompletedUtc).FirstOrDefaultAsync())?.Score;
public async Task<ExamSimulation?> GetOwnedAsync(string id, string userId)
{
var simulation = await _simulations.Find(item => item.Id == id && item.UserId == userId).FirstOrDefaultAsync();
+95
View File
@@ -0,0 +1,95 @@
{
"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"] }
],
"cases": [
{
"externalId": "ccs-endocrine-dka-emergency-001",
"isPublished": true,
"title": "Vomiting and progressive lethargy",
"system": "Endocrine System",
"presentingLocation": "emergency_department",
"realTimeMinutes": 20,
"difficulty": 2,
"tags": ["diabetic ketoacidosis", "hyperglycemic crisis", "fluid resuscitation", "insulin", "potassium"],
"initialChart": {
"patientSummary": "24-year-old woman with type 1 diabetes mellitus",
"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" },
"simulatedStartUtc": "2026-07-15T08:00:00Z",
"initialOrderCodes": []
},
"allowedLocations": ["emergency_department", "icu", "ward"],
"exams": [
{ "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." },
{ "code": "cardiopulmonary_exam", "name": "Cardiopulmonary examination", "durationMinutes": 1, "finding": "Tachycardic with regular rhythm; lungs are clear. Respirations are deep without wheezing or crackles." },
{ "code": "abdominal_exam", "name": "Abdominal examination", "durationMinutes": 1, "finding": "Mild diffuse tenderness without guarding, rebound, or focal peritoneal findings." },
{ "code": "neurologic_exam", "name": "Neurologic examination", "durationMinutes": 1, "finding": "She opens her eyes to voice, follows simple commands, and has no focal deficit." }
],
"orderResponses": [
{ "orderCode": "normal_saline_bolus", "turnaroundMinutes": 0, "result": "Isotonic crystalloid infusion is initiated.", "vitalPatches": { "bloodPressure": "104/66 mm Hg", "pulse": "102/min" } },
{ "orderCode": "balanced_crystalloid_bolus", "turnaroundMinutes": 0, "result": "Balanced crystalloid infusion is initiated.", "vitalPatches": { "bloodPressure": "104/66 mm Hg", "pulse": "102/min" } },
{ "orderCode": "bmp", "turnaroundMinutes": 12, "result": "Basic metabolic panel: sodium 131 mEq/L, potassium 5.3 mEq/L, chloride 91 mEq/L, bicarbonate 7 mEq/L, glucose 580 mg/dL, BUN 34 mg/dL, creatinine 1.8 mg/dL. Anion gap is 33." },
{ "orderCode": "venous_blood_gas", "turnaroundMinutes": 8, "result": "Venous blood gas: pH 7.08, PCO2 22 mm Hg." },
{ "orderCode": "beta_hydroxybutyrate", "turnaroundMinutes": 15, "result": "Serum beta-hydroxybutyrate is 7.8 mmol/L." },
{ "orderCode": "cbc", "turnaroundMinutes": 12, "result": "Leukocyte count is 14,200/mm3, hemoglobin is 15.1 g/dL, and platelet count is 286,000/mm3." },
{ "orderCode": "urinalysis", "turnaroundMinutes": 15, "result": "Urinalysis shows 4+ glucose, 3+ ketones, and no leukocyte esterase or nitrites." },
{ "orderCode": "pregnancy_test", "turnaroundMinutes": 15, "result": "Serum beta-hCG is negative." },
{ "orderCode": "ecg", "turnaroundMinutes": 10, "result": "ECG shows sinus tachycardia without acute ischemic changes or marked T-wave peaking." },
{ "orderCode": "insulin_infusion", "turnaroundMinutes": 0, "result": "Regular insulin infusion is initiated." },
{ "orderCode": "potassium_chloride_iv", "turnaroundMinutes": 0, "result": "Potassium supplementation is added to intravenous fluid." },
{ "orderCode": "dextrose_half_normal_saline", "turnaroundMinutes": 0, "result": "Dextrose-containing intravenous fluid is initiated while insulin continues." },
{ "orderCode": "serial_bmp", "turnaroundMinutes": 240, "result": "Repeat chemistry: sodium 136 mEq/L, potassium 4.0 mEq/L, chloride 101 mEq/L, bicarbonate 13 mEq/L, glucose 190 mg/dL. Anion gap is 22." },
{ "orderCode": "sodium_bicarbonate_iv", "turnaroundMinutes": 0, "result": "Sodium bicarbonate is administered despite pH above the threshold for routine use." }
],
"events": [
{ "id": "volume_response", "triggerMinute": 15, "requiresOrderCodes": [], "requiresAnyOrderCodes": ["normal_saline_bolus", "balanced_crystalloid_bolus"], "message": "After initial fluid resuscitation, perfusion and alertness improve.", "vitalPatches": { "bloodPressure": "108/68 mm Hg", "pulse": "98/min" }, "endCase": false },
{ "id": "metabolic_improvement", "triggerMinute": 270, "requiresOrderCodes": ["insulin_infusion", "potassium_chloride_iv", "dextrose_half_normal_saline", "serial_bmp"], "message": "Glucose and acidosis continue to improve without hypoglycemia or hypokalemia. Case-end instructions are available.", "vitalPatches": { "bloodPressure": "112/70 mm Hg", "pulse": "88/min", "respirations": "18/min" }, "endCase": true }
],
"scoringCriteria": [
{ "id": "assess_hydration", "actionType": "exam", "targetCode": "general_exam", "points": 4, "latestMinute": 10, "afterTargetCode": "", "rationale": "Early assessment should identify severe volume depletion and altered mentation." },
{ "id": "iv_access", "actionType": "order", "targetCode": "iv_access", "points": 4, "latestMinute": 5, "afterTargetCode": "", "rationale": "Prompt intravenous access is needed for fluid resuscitation and treatment." },
{ "id": "cardiac_monitor", "actionType": "order", "targetCode": "cardiac_monitor", "points": 3, "latestMinute": 10, "afterTargetCode": "", "rationale": "Potassium shifts during DKA treatment create arrhythmia risk." },
{ "id": "fluid_resuscitation", "actionType": "order", "targetCode": "", "targetCodes": ["normal_saline_bolus", "balanced_crystalloid_bolus"], "points": 12, "latestMinute": 10, "afterTargetCode": "", "rationale": "Immediate isotonic crystalloid treats severe intravascular depletion and hypotension." },
{ "id": "electrolytes", "actionType": "order", "targetCode": "bmp", "points": 8, "latestMinute": 10, "afterTargetCode": "", "rationale": "Electrolytes, renal function, glucose, bicarbonate, and anion gap guide diagnosis and safe treatment." },
{ "id": "ketone_confirmation", "actionType": "order", "targetCode": "beta_hydroxybutyrate", "points": 5, "latestMinute": 20, "afterTargetCode": "", "rationale": "Direct beta-hydroxybutyrate measurement confirms and monitors clinically important ketonemia." },
{ "id": "acid_base", "actionType": "order", "targetCode": "venous_blood_gas", "points": 5, "latestMinute": 20, "afterTargetCode": "", "rationale": "Venous pH establishes the severity of metabolic acidosis without requiring an arterial sample." },
{ "id": "pregnancy", "actionType": "order", "targetCode": "pregnancy_test", "points": 3, "latestMinute": 30, "afterTargetCode": "", "rationale": "Pregnancy status affects the differential diagnosis, monitoring, and disposition in a reproductive-age patient." },
{ "id": "insulin_after_potassium", "actionType": "order", "targetCode": "insulin_infusion", "points": 15, "latestMinute": 30, "afterTargetCode": "bmp", "rationale": "Insulin suppresses ketogenesis but should follow assessment of serum potassium." },
{ "id": "hourly_glucose", "actionType": "order", "targetCode": "hourly_glucose", "points": 5, "latestMinute": 35, "afterTargetCode": "insulin_infusion", "rationale": "Frequent glucose monitoring is required during continuous insulin therapy." },
{ "id": "serial_electrolytes", "actionType": "order", "targetCode": "serial_bmp", "points": 8, "latestMinute": 40, "afterTargetCode": "insulin_infusion", "rationale": "Serial chemistry identifies falling potassium and tracks resolution of acidosis." },
{ "id": "potassium_replacement", "actionType": "order", "targetCode": "potassium_chloride_iv", "points": 10, "earliestMinute": 12, "latestMinute": 260, "afterTargetCode": "bmp", "rationale": "Total-body potassium is depleted and replacement is needed as serum potassium falls during therapy." },
{ "id": "dextrose_with_continued_insulin", "actionType": "order", "targetCode": "dextrose_half_normal_saline", "points": 10, "earliestMinute": 240, "latestMinute": 300, "afterTargetCode": "insulin_infusion", "rationale": "When glucose approaches 200 mg/dL before ketoacidosis resolves, add dextrose so insulin can continue safely." },
{ "id": "icu_disposition", "actionType": "order", "targetCode": "icu_admission", "points": 8, "latestMinute": 60, "afterTargetCode": "", "rationale": "Severe DKA with hypotension and altered mentation requires closely monitored inpatient care." },
{ "id": "avoid_bicarbonate", "actionType": "order", "targetCode": "sodium_bicarbonate_iv", "points": -10, "afterTargetCode": "", "rationale": "Routine bicarbonate provides no benefit at pH 7.08 and may worsen potassium shifts and other complications." },
{ "id": "avoid_empiric_antibiotic", "actionType": "order", "targetCode": "ceftriaxone", "points": -4, "afterTargetCode": "", "rationale": "No history, examination, or urine findings support bacterial infection requiring empiric ceftriaxone." }
],
"references": [
{ "title": "Hyperglycemic Crises in Adults With Diabetes: A Consensus Report", "publisher": "American Diabetes Association, EASD, AACE, JBDS-IP, and Diabetes Technology Society", "url": "https://diabetesjournals.org/care/article/47/8/1257/156808/Hyperglycemic-Crises-in-Adults-With-Diabetes-A", "supports": "DKA diagnosis, fluid resuscitation, insulin and potassium sequencing, dextrose addition, monitoring, bicarbonate avoidance, and resolution criteria", "accessedOn": "2026-07-15", "reviewer": "Question bank editorial review" },
{ "title": "Glycemic Goals, Hypoglycemia, and Hyperglycemic Crises: Standards of Care in Diabetes-2026", "publisher": "American Diabetes Association", "url": "https://pmc.ncbi.nlm.nih.gov/articles/PMC12690178/", "supports": "Current hyperglycemic-crisis recognition, management, monitoring, and prevention recommendations", "accessedOn": "2026-07-15", "reviewer": "Question bank editorial review" }
]
}
],
"validateOnly": false
}
+21
View File
@@ -0,0 +1,21 @@
# CCS case authoring
CCS content is imported through `POST /api/ccs/admin/import` by a `SUPER` user. The import payload contains reusable order-catalog entries and one or more versioned cases. Published cases require complete embedded references and pass the same validation in dry-run and live imports.
Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validateOnly` to `true` first, then submit the same payload with `false`. Updating an existing `externalId` creates a new immutable revision; active and completed attempts remain attached to the revision they started with.
## Case structure
- `initialChart` defines the opening history, appearance, vitals, location, simulated start, and standing orders.
- `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.
- `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
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.
+1
View File
@@ -25,6 +25,7 @@ ProgressionService="*res://scripts/autoload/progression_service.gd"
ProfileService="*res://scripts/autoload/profile_service.gd"
BlockSessionService="*res://scripts/autoload/block_session_service.gd"
ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd"
CCSService="*res://scripts/autoload/ccs_service.gd"
[display]
+138
View File
@@ -0,0 +1,138 @@
extends Node
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_API_URL := "https://s3q.ranaze.com/api/ccs"
signal cases_changed
signal attempt_changed
signal orders_changed
signal history_changed
signal prediction_changed
signal request_failed(message: String)
var api_url := DEFAULT_API_URL
var request: HTTPRequest
var cases: Array = []
var attempt := {}
var orders: Array = []
var history: Array = []
var prediction := {}
var is_loading := false
var pending_action := ""
var queue: Array = []
func _ready() -> void:
api_url = EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/ccs_api_url", DEFAULT_API_URL)), "/api/ccs")
request = HTTPRequest.new()
request.request_completed.connect(_on_request_completed)
add_child(request)
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 load_attempt(attempt_id: String) -> void:
_send("attempt", HTTPClient.METHOD_GET, "/attempts/%s" % attempt_id.uri_encode())
func send_command(type: String, code: String = "", minutes: int = 0) -> void:
if attempt.is_empty():
return
_send("attempt", HTTPClient.METHOD_POST, "/attempts/%s/commands" % str(attempt.get("id", "")).uri_encode(), {
"commandId": "%d-%d" % [Time.get_ticks_usec(), randi()],
"expectedRevision": int(attempt.get("revision", 0)),
"type": type,
"code": code,
"minutes": minutes
})
func search_orders(query: String) -> void:
if query.strip_edges().length() < 2:
orders = []
orders_changed.emit()
return
_send("orders", HTTPClient.METHOD_GET, "/orders/search?q=%s&limit=20" % query.strip_edges().uri_encode())
func load_history() -> void:
_send("history", HTTPClient.METHOD_GET, "/history?limit=50")
func load_prediction() -> void:
_send("prediction", HTTPClient.METHOD_GET, "/prediction")
func abandon() -> void:
if attempt.is_empty():
return
_send("abandon", HTTPClient.METHOD_POST, "/attempts/%s/abandon" % str(attempt.get("id", "")).uri_encode())
func deadline_unix() -> float:
var value := str(attempt.get("finalOrdersDeadlineUtc", attempt.get("deadlineUtc", "")))
return 0.0 if value.is_empty() or value == "<null>" else Time.get_unix_time_from_datetime_string(value)
func _send(action: String, method: int, suffix: String, payload: Dictionary = {}) -> void:
var url := "%s%s" % [api_url.rstrip("/"), suffix]
if is_loading:
queue.append({"action": action, "method": method, "url": url, "payload": payload.duplicate(true)})
return
if AuthState.access_token.is_empty():
return
is_loading = true
pending_action = action
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
var body := ""
if not payload.is_empty():
headers.append("Content-Type: application/json")
body = JSON.stringify(payload)
var error := request.request(url, headers, method, body)
if error != OK:
is_loading = false
request_failed.emit("The CCS request could not be started.")
func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var action := pending_action
pending_action = ""
is_loading = false
call_deferred("_process_queue")
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var detail := body.get_string_from_utf8().strip_edges().left(240)
request_failed.emit("CCS request failed (%d). %s" % [response_code, detail])
return
if response_code == 204:
attempt = {}
attempt_changed.emit()
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if action in ["cases", "orders", "history"] and typeof(parsed) != TYPE_ARRAY:
request_failed.emit("The CCS API returned an invalid list.")
return
if action not in ["cases", "orders", "history"] and typeof(parsed) != TYPE_DICTIONARY:
request_failed.emit("The CCS API returned an invalid response.")
return
match action:
"cases": cases = parsed; cases_changed.emit()
"orders": orders = parsed; orders_changed.emit()
"history": history = parsed; history_changed.emit()
"prediction": prediction = parsed; prediction_changed.emit()
_:
attempt = parsed
attempt_changed.emit()
func _process_queue() -> void:
if is_loading or queue.is_empty():
return
var queued: Dictionary = queue.pop_front()
var suffix := str(queued.get("url", "")).trim_prefix(api_url.rstrip("/"))
_send(str(queued.get("action", "")), int(queued.get("method", HTTPClient.METHOD_GET)), suffix, queued.get("payload", {}))
+1
View File
@@ -0,0 +1 @@
uid://d3gcghtwmgxtt
+41
View File
@@ -0,0 +1,41 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var list: VBoxContainer
var prediction: Label
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 12)
var back := Button.new(); back.text = "< Profile"; back.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN; back.pressed.connect(func(): main.show_screen("profile")); add_child(back)
var title := Label.new(); title.text = "CCS Case History"; Style.set_label(title, 32, Style.TEXT); add_child(title)
prediction = Label.new(); prediction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; Style.set_label(prediction, 16, Style.CYAN); add_child(prediction)
var scroll := ScrollContainer.new(); scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL; add_child(scroll)
list = VBoxContainer.new(); list.size_flags_horizontal = Control.SIZE_EXPAND_FILL; list.add_theme_constant_override("separation", 8); scroll.add_child(list)
CCSService.history_changed.connect(_render)
CCSService.prediction_changed.connect(_render_prediction)
func on_show(_payload: Variant = null) -> void:
CCSService.load_history(); CCSService.load_prediction()
func _render() -> void:
for child in list.get_children(): child.queue_free()
if CCSService.history.is_empty():
var empty := Label.new(); empty.text = "No case attempts yet."; Style.set_label(empty, 16, Style.MUTED); list.add_child(empty); return
for item in CCSService.history:
var button := Button.new(); button.alignment = HORIZONTAL_ALIGNMENT_LEFT
var score: Variant = item.get("score", null)
button.text = "%s\n%s | %s%s" % [item.get("caseTitle", "Case"), str(item.get("mode", "")).capitalize(), str(item.get("state", "")).capitalize(), " | %d%%" % int(round(float(score))) if score != null else ""]
button.disabled = str(item.get("state", "")) == "abandoned"
button.pressed.connect(_open.bind(str(item.get("id", "")), str(item.get("state", "")))); list.add_child(button)
func _open(id: String, state: String) -> void:
main.show_screen("ccs_review" if state == "completed" else "ccs_player", id)
func _render_prediction() -> void:
var data: Dictionary = CCSService.prediction
if data.get("combinedPredictedScore", null) != null:
prediction.text = "Provisional combined prediction: %d (range %d-%d) | CCS %.0f%% from %d first timed cases" % [data.get("combinedPredictedScore", 0), data.get("rangeLow", 0), data.get("rangeHigh", 0), data.get("ccsScore", 0), data.get("contributingCases", 0)]
elif data.get("ccsScore", null) != null:
prediction.text = "CCS performance: %.0f%% from %d first timed cases. Complete a Day 1 simulation for a combined prediction." % [data.get("ccsScore", 0), data.get("contributingCases", 0)]
else: prediction.text = "Complete timed CCS cases to begin estimating CCS performance."
+1
View File
@@ -0,0 +1 @@
uid://cs2vdscnt4h4j
+44
View File
@@ -0,0 +1,44 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var list: VBoxContainer
var status: Label
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 14)
var back := Button.new(); back.text = "< All exams"; back.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_chip(back, Style.CYAN); back.pressed.connect(func(): main.show_screen("selection", "exam")); add_child(back)
var title := Label.new(); title.text = "CCS Case Practice"; Style.set_label(title, 34, Style.TEXT); add_child(title)
var note := Label.new(); note.text = "Manage a simulated patient through examination, orders, treatment, location, and time."; Style.set_label(note, 16, Style.MUTED); add_child(note)
status = Label.new(); Style.set_label(status, 15, Style.CYAN); add_child(status)
var scroll := ScrollContainer.new(); scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL; add_child(scroll)
list = VBoxContainer.new(); list.size_flags_horizontal = Control.SIZE_EXPAND_FILL; list.add_theme_constant_override("separation", 10); scroll.add_child(list)
var history := Button.new(); history.text = "Case History"; history.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_chip(history, Style.CYAN); history.pressed.connect(func(): main.show_screen("ccs_history")); add_child(history)
CCSService.cases_changed.connect(_render)
CCSService.attempt_changed.connect(_attempt_ready)
CCSService.request_failed.connect(func(message: String): status.text = message)
func on_show(_payload: Variant = null) -> void:
status.text = "Loading cases..."
CCSService.load_cases()
func _render() -> void:
for child in list.get_children(): child.queue_free()
status.text = "No published cases are available yet." if CCSService.cases.is_empty() else "%d cases available" % CCSService.cases.size()
for case in CCSService.cases:
var panel := PanelContainer.new(); panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE_DARK, Color("405475"))); list.add_child(panel)
var row := HBoxContainer.new(); row.add_theme_constant_override("separation", 12); panel.add_child(row)
var details := Label.new(); details.text = "%s\n%s | %d-minute case" % [case.get("title", "Untitled case"), case.get("system", ""), case.get("realTimeMinutes", 20)]
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL; Style.set_label(details, 18, Style.TEXT); row.add_child(details)
var practice := Button.new(); practice.text = "Practice"; Style.style_chip(practice, Style.CYAN); practice.pressed.connect(_start.bind(str(case.get("externalId", "")), "practice")); row.add_child(practice)
var timed := Button.new(); timed.text = "Timed"; Style.style_primary_button(timed); timed.pressed.connect(_start.bind(str(case.get("externalId", "")), "timed")); row.add_child(timed)
func _start(case_id: String, mode: String) -> void:
status.text = "Starting case..."
CCSService.create_attempt(case_id, mode)
func _attempt_ready() -> void:
if visible and not CCSService.attempt.is_empty(): main.show_screen("ccs_player")
+1
View File
@@ -0,0 +1 @@
uid://d2d66msil5rl5
+111
View File
@@ -0,0 +1,111 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var heading: Label
var clock: Label
var summary: RichTextLabel
var chart: RichTextLabel
var orders: VBoxContainer
var search: LineEdit
var search_results: VBoxContainer
var actions: VBoxContainer
var status: Label
var timer: Timer
var end_button: Button
var time_row: HBoxContainer
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 8)
var top := HBoxContainer.new(); add_child(top)
heading = Label.new(); heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL; Style.set_label(heading, 25, Style.TEXT); top.add_child(heading)
clock = Label.new(); Style.set_label(clock, 18, Style.AMBER); top.add_child(clock)
var columns := HSplitContainer.new(); columns.size_flags_vertical = Control.SIZE_EXPAND_FILL; columns.split_offset = 850; add_child(columns)
var chart_panel := VBoxContainer.new(); chart_panel.custom_minimum_size.x = 500; columns.add_child(chart_panel)
summary = RichTextLabel.new(); summary.bbcode_enabled = true; summary.custom_minimum_size.y = 160; chart_panel.add_child(summary)
chart = RichTextLabel.new(); chart.bbcode_enabled = true; chart.size_flags_vertical = Control.SIZE_EXPAND_FILL; chart_panel.add_child(chart)
var clerk_scroll := ScrollContainer.new(); clerk_scroll.custom_minimum_size.x = 430; columns.add_child(clerk_scroll)
var clerk := VBoxContainer.new(); clerk.size_flags_horizontal = Control.SIZE_EXPAND_FILL; clerk.add_theme_constant_override("separation", 8); clerk_scroll.add_child(clerk)
var order_title := Label.new(); order_title.text = "Order Clerk"; Style.set_label(order_title, 21, Style.CYAN); clerk.add_child(order_title)
search = LineEdit.new(); search.placeholder_text = "Search tests, treatments, and procedures"; search.text_submitted.connect(func(_value): CCSService.search_orders(search.text)); clerk.add_child(search)
var find := Button.new(); find.text = "Search"; find.pressed.connect(func(): CCSService.search_orders(search.text)); clerk.add_child(find)
search_results = VBoxContainer.new(); clerk.add_child(search_results)
var active_title := Label.new(); active_title.text = "Active orders"; Style.set_label(active_title, 18, Style.TEXT); clerk.add_child(active_title)
orders = VBoxContainer.new(); clerk.add_child(orders)
var exam_title := Label.new(); exam_title.text = "Physical examination"; Style.set_label(exam_title, 18, Style.TEXT); clerk.add_child(exam_title)
actions = VBoxContainer.new(); clerk.add_child(actions)
time_row = HBoxContainer.new(); clerk.add_child(time_row)
for minutes in [1, 10, 60, 1440]:
var button := Button.new(); button.text = "+%s" % ("1 day" if minutes == 1440 else "%d min" % minutes); button.pressed.connect(CCSService.send_command.bind("advance", "", minutes)); time_row.add_child(button)
var reassess := Button.new(); reassess.text = "Interval history"; reassess.pressed.connect(CCSService.send_command.bind("interval_history", "", 0)); clerk.add_child(reassess)
end_button = Button.new(); end_button.text = "End case"; Style.style_chip(end_button, Style.AMBER); end_button.pressed.connect(_end_case); clerk.add_child(end_button)
var leave := Button.new(); leave.text = "Leave case"; Style.style_chip(leave, Style.RED); leave.pressed.connect(_leave); clerk.add_child(leave)
status = Label.new(); Style.set_label(status, 14, Style.RED); add_child(status)
timer = Timer.new(); timer.wait_time = 1; timer.timeout.connect(_tick); add_child(timer)
CCSService.attempt_changed.connect(_render)
CCSService.orders_changed.connect(_render_search)
CCSService.request_failed.connect(func(message: String): status.text = message)
func on_show(payload: Variant = null) -> void:
status.text = ""
if typeof(payload) == TYPE_STRING and not str(payload).is_empty(): CCSService.load_attempt(str(payload))
_render(); timer.start()
func on_hide() -> void: timer.stop()
func _render() -> void:
var attempt: Dictionary = CCSService.attempt
if attempt.is_empty(): return
if str(attempt.get("state", "")) == "completed": main.show_screen("ccs_review", attempt); return
heading.text = "%s | %s" % [attempt.get("caseTitle", "CCS case"), str(attempt.get("location", "")).capitalize()]
summary.text = "[b]%s[/b]\n%s\n\n[b]Vitals[/b] %s" % [attempt.get("patientSummary", ""), attempt.get("reasonForEncounter", ""), _vitals_text(attempt.get("vitals", {}))]
chart.text = "[b]History[/b]\n%s\n\n[b]Appearance[/b]\n%s\n\n[b]Chart[/b]\n%s" % [attempt.get("history", ""), attempt.get("appearance", ""), _chart_text(attempt.get("chart", []))]
_clear(orders)
for code in attempt.get("activeOrderCodes", []):
var row := HBoxContainer.new(); orders.add_child(row)
var label := Label.new(); label.text = str(code); label.size_flags_horizontal = Control.SIZE_EXPAND_FILL; row.add_child(label)
var cancel := Button.new(); cancel.text = "Cancel"; cancel.pressed.connect(CCSService.send_command.bind("cancel_order", str(code), 0)); row.add_child(cancel)
_clear(actions)
for exam in attempt.get("availableExams", []):
var button := Button.new(); button.text = str(exam.get("name", exam.get("code", "Exam"))); button.pressed.connect(CCSService.send_command.bind("exam", str(exam.get("code", "")), 0)); actions.add_child(button)
for location in attempt.get("allowedLocations", []):
var button := Button.new(); button.text = "Move to %s" % str(location).capitalize(); button.pressed.connect(CCSService.send_command.bind("location", str(location), 0)); actions.add_child(button)
_tick()
end_button.text = "Submit final orders" if str(attempt.get("state", "")) == "final_orders" else "End case"
time_row.visible = str(attempt.get("state", "")) == "active"
if str(attempt.get("state", "")) == "final_orders": status.text = "Case-end instructions: finalize or cancel orders, then submit."
func _render_search() -> void:
_clear(search_results)
for order in CCSService.orders:
var button := Button.new(); button.text = "%s (%s)" % [order.get("canonicalName", ""), order.get("category", "")]; button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.pressed.connect(_place_order.bind(str(order.get("code", "")))); search_results.add_child(button)
func _place_order(code: String) -> void:
CCSService.send_command("order", code); search.text = ""; CCSService.orders = []; _render_search()
func _tick() -> void:
var deadline := CCSService.deadline_unix()
if deadline <= 0: clock.text = "Practice | Case time %d min" % int(CCSService.attempt.get("simulatedMinute", 0)); return
var remaining := maxi(0, int(ceil(deadline - Time.get_unix_time_from_system())))
clock.text = "%02d:%02d | Case time %d min" % [floori(float(remaining) / 60.0), remaining % 60, int(CCSService.attempt.get("simulatedMinute", 0))]
if remaining == 0 and not CCSService.is_loading: CCSService.load_attempt(str(CCSService.attempt.get("id", "")))
func _leave() -> void:
CCSService.abandon(); main.show_screen("ccs_hub")
func _end_case() -> void:
CCSService.send_command("exit_case" if str(CCSService.attempt.get("state", "")) == "final_orders" else "end_case")
func _clear(parent: Node) -> void:
for child in parent.get_children(): child.queue_free()
func _vitals_text(vitals: Dictionary) -> String:
var parts: Array[String] = []
for key in vitals: parts.append("%s: %s" % [str(key), str(vitals[key])])
return " | ".join(parts)
func _chart_text(entries: Array) -> String:
var lines: Array[String] = []
for entry in entries: lines.append("T+%d [%s] %s" % [entry.get("simulatedMinute", 0), entry.get("category", "note"), entry.get("text", "")])
return "\n\n".join(lines)
+1
View File
@@ -0,0 +1 @@
uid://djuxbj74nhiji
+38
View File
@@ -0,0 +1,38 @@
extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var title: Label
var body: RichTextLabel
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 12)
title = Label.new(); Style.set_label(title, 32, Style.TEXT); add_child(title)
body = RichTextLabel.new(); body.bbcode_enabled = true; body.fit_content = false; body.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.meta_clicked.connect(func(meta): OS.shell_open(str(meta))); add_child(body)
CCSService.attempt_changed.connect(_refresh_loaded_attempt)
var row := HBoxContainer.new(); add_child(row)
var cases := Button.new(); cases.text = "More cases"; Style.style_primary_button(cases); cases.pressed.connect(func(): main.show_screen("ccs_hub")); row.add_child(cases)
var history := Button.new(); history.text = "Case history"; history.pressed.connect(func(): main.show_screen("ccs_history")); row.add_child(history)
func on_show(payload: Variant = null) -> void:
if typeof(payload) == TYPE_STRING:
CCSService.load_attempt(str(payload))
return
var attempt: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else CCSService.attempt
if attempt.is_empty(): return
var score: Dictionary = attempt.get("score", {})
title.text = "%s: %d%%" % [attempt.get("caseTitle", "Case review"), int(round(float(score.get("percent", 0))))]
var text := "[b]Scoring review[/b]\n"
for criterion in score.get("criteria", []):
var status := str(criterion.get("status", "missed"))
text += "\n[color=%s][b]%s %s%d[/b][/color]\n%s\n" % [Style.LIME.to_html() if status in ["credited", "avoided"] else Style.RED.to_html(), status.capitalize(), "+" if int(criterion.get("points", 0)) >= 0 else "", criterion.get("points", 0), criterion.get("rationale", "")]
text += "\n[b]Case timeline[/b]\n"
for entry in attempt.get("chart", []): text += "\nT+%d [%s] %s" % [entry.get("simulatedMinute", 0), entry.get("category", "note"), entry.get("text", "")]
if not attempt.get("references", []).is_empty():
text += "\n\n[b]References[/b]\n"
for reference in attempt.get("references", []): text += "\n[url=%s]%s[/url]" % [reference.get("url", ""), reference.get("title", reference.get("url", "Reference"))]
body.text = text
func _refresh_loaded_attempt() -> void:
if visible and str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt)
+1
View File
@@ -0,0 +1 @@
uid://cjpj86t7rnvy6
+10 -1
View File
@@ -47,7 +47,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void:
current_screen_name = screen_name
screen.visible = true
if header_panel != null:
header_panel.visible = screen_name != "login" and screen_name != "player"
header_panel.visible = screen_name not in ["login", "player", "ccs_player"]
if screen.has_method("on_show"):
screen.on_show(payload)
@@ -179,6 +179,15 @@ func _build_ui() -> void:
screens_container.add_child(simulation_history)
screens["simulation_history"] = simulation_history
var ccs_hub := preload("res://scripts/ui/ccs_hub.gd").new()
ccs_hub.name = "CcsHub"; ccs_hub.main = self; screens_container.add_child(ccs_hub); screens["ccs_hub"] = ccs_hub
var ccs_player := preload("res://scripts/ui/ccs_player.gd").new()
ccs_player.name = "CcsPlayer"; ccs_player.main = self; screens_container.add_child(ccs_player); screens["ccs_player"] = ccs_player
var ccs_review := preload("res://scripts/ui/ccs_review.gd").new()
ccs_review.name = "CcsReview"; ccs_review.main = self; screens_container.add_child(ccs_review); screens["ccs_review"] = ccs_review
var ccs_history := preload("res://scripts/ui/ccs_history.gd").new()
ccs_history.name = "CcsHistory"; ccs_history.main = self; screens_container.add_child(ccs_history); screens["ccs_history"] = ccs_history
var profile := preload("res://scripts/ui/progress_profile.gd").new()
profile.name = "ProgressProfile"
profile.main = self
+6
View File
@@ -63,6 +63,12 @@ func _ready() -> void:
simulations.pressed.connect(func(): main.show_screen("simulation_history"))
row.add_child(simulations)
var ccs_history := Button.new()
ccs_history.text = "CCS History"
Style.style_chip(ccs_history, Style.CYAN)
ccs_history.pressed.connect(func(): main.show_screen("ccs_history"))
row.add_child(ccs_history)
reset_qbank_button = Button.new()
reset_qbank_button.text = "Reset QBank"
Style.style_chip(reset_qbank_button, Style.AMBER)
+4 -3
View File
@@ -88,7 +88,7 @@ func _build_exam_view() -> void:
_add_exam_option("STEP 1", "Foundational sciences", false)
_add_exam_option("STEP 2", "Clinical knowledge", false)
_add_exam_option("STEP 3", "Advanced clinical management", true)
_add_exam_option("CCS Cases", "Interactive case simulations", false)
_add_exam_option("CCS Cases", "Interactive case simulations", true, "ccs")
func _build_mode_view() -> void:
@@ -197,7 +197,7 @@ func _build_mode_view() -> void:
mode_view.add_child(spacer)
func _add_exam_option(option_name: String, description: String, available: bool) -> void:
func _add_exam_option(option_name: String, description: String, available: bool, destination: String = "step3") -> void:
var button := Button.new()
button.text = "%s\n%s | %s" % [option_name, description, "Available" if available else "Coming Soon"]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
@@ -208,7 +208,8 @@ func _add_exam_option(option_name: String, description: String, available: bool)
button.disabled = not available
if available:
Style.style_primary_button(button)
button.pressed.connect(func(): _show_stage("step3"))
if destination == "ccs": button.pressed.connect(func(): main.show_screen("ccs_hub"))
else: button.pressed.connect(func(): _show_stage("step3"))
exam_grid.add_child(button)