Add interactive CCS case practice
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user