using MongoDB.Bson; using MongoDB.Driver; using QuestionsApi.Models; using System.Text; namespace QuestionsApi.Services; public sealed class CcsStore { private readonly IMongoCollection _cases; private readonly IMongoCollection _orders; private readonly IMongoCollection _attempts; private readonly ExamSimulationStore _examSimulations; private readonly CcsRatingStore _ratings; public CcsStore(MongoDatabaseProvider mongo, ExamSimulationStore examSimulations, CcsRatingStore ratings) { _cases = mongo.Database.GetCollection("CcsCaseRevisions"); _orders = mongo.Database.GetCollection("CcsOrderCatalog"); _attempts = mongo.Database.GetCollection("CcsAttempts"); _examSimulations = examSimulations; _ratings = ratings; _cases.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version), new CreateIndexOptions { Unique = true })); _cases.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.IsCurrent).Ascending(value => value.IsPublished))); _attempts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State).Descending(value => value.UpdatedUtc))); _attempts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc))); _attempts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc))); _attempts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.ActiveSlot), new CreateIndexOptions { Unique = true, Sparse = true, Name = "unique_active_ccs_attempt" })); } public async Task ImportAsync(CcsImportRequest request) { var existingCodes = (await _orders.Find(Builders.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.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> ListCasesAsync() => (await _cases.Find(value => value.IsCurrent && value.IsPublished).SortBy(value => value.Title).ToListAsync()) .Select(ToSummary).ToList(); public async Task> SearchOrdersAsync(string query, int limit) { query = query.Trim().ToLowerInvariant(); if (query.Length < 2) return []; var all = await _orders.Find(Builders.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 CreateAttemptAsync(string userId, CreateCcsAttemptRequest request) { var validation = CcsCaseValidator.ValidateCreate(request); if (validation is not null) throw new ArgumentException(validation); if (await FindActiveAsync(userId) is not null) throw new InvalidOperationException("An active CCS case already exists."); 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, ActiveSlot = userId, CaseExternalId = definition.ExternalId, CaseRevisionId = definition.Id ?? "", CaseVersion = definition.Version, Mode = CcsAttemptModes.Timed, OpeningCompleted = false, InterviewNodeId = definition.InitialInterview.StartNodeId, Location = definition.PresentingLocation, CurrentVitals = new Dictionary(definition.InitialChart.Vitals), VitalHistory = [ new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary(definition.InitialChart.Vitals) } ], ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)), HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes), RatingEligible = true, Chart = [ new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter }, new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "initial_appearance", Title = "Initial appearance", Text = definition.InitialChart.Appearance } ] }; try { await _attempts.InsertOneAsync(attempt); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { throw new InvalidOperationException("An active CCS case already exists.", exception); } return attempt; } public async Task FindActiveAsync(string userId) { var unfinished = await _attempts.Find(value => value.UserId == userId && (value.State == CcsAttemptStates.Active || value.State == CcsAttemptStates.FinalOrders)) .SortByDescending(value => value.UpdatedUtc).ThenByDescending(value => value.Id).ToListAsync(); if (unfinished.Count == 0) return null; var retained = unfinished[0]; foreach (var older in unfinished.Skip(1)) { older.State = CcsAttemptStates.Abandoned; older.ActiveSlot = null; older.UpdatedUtc = DateTime.UtcNow; await SaveAsync(older); } retained = await GetOwnedAsync(retained.Id ?? "", userId) ?? retained; if (retained.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned) return null; if (retained.ActiveSlot != userId) { retained.ActiveSlot = userId; retained.UpdatedUtc = DateTime.UtcNow; try { await SaveAsync(retained); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { return await _attempts.Find(value => value.ActiveSlot == userId).FirstOrDefaultAsync(); } } return retained; } public async Task 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 = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc); attempt.Chart.Add(new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions", SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders." }); attempt.Revision++; await SaveAsync(attempt); } if (attempt.State == CcsAttemptStates.FinalOrders && attempt.FinalOrdersDeadlineUtc <= DateTime.UtcNow) { await CompleteAsync(attempt); } return attempt; } public async Task 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 = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." }); Advance(attempt, definition, 1); break; case "acknowledge_opening": attempt.OpeningCompleted = true; break; case "dialogue_choice": ApplyDialogueChoice(attempt, definition, command.Code, now); 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 ApplyBatchCommandAsync(CcsAttempt attempt, CcsBatchCommandRequest request) { var validation = CcsCaseValidator.ValidateBatch(request); if (validation is not null) throw new ArgumentException(validation); if (attempt.ProcessedCommandIds.Contains(request.CommandId, StringComparer.Ordinal)) return attempt; if (attempt.Revision != request.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale."); if (attempt.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned) throw new InvalidOperationException("The CCS attempt is closed."); if (attempt.State == CcsAttemptStates.FinalOrders && request.Commands.Any(value => value.Type is not ("order" or "cancel_order"))) throw new InvalidOperationException("Only final orders may be changed now."); var definition = await DefinitionAsync(attempt); var now = DateTime.UtcNow; if (request.Commands.Any(value => value.Type is "exam" or "interval_history")) { var selected = request.Commands.Where(item => item.Type == "exam").Select(item => definition.Exams.FirstOrDefault(value => value.Code.Equals(item.Code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException($"Unknown examination component: {item.Code}.")).ToList(); var includeHistory = request.Commands.Any(item => item.Type == "interval_history"); var startingMinute = attempt.SimulatedMinute; foreach (var exam in selected) attempt.Actions.Add(Action("exam", exam.Code, startingMinute, now)); if (includeHistory) attempt.Actions.Add(Action("interval_history", "interval_history", startingMinute, now)); Advance(attempt, definition, selected.Sum(value => Math.Max(1, value.DurationMinutes)) + (includeHistory ? 1 : 0)); if (includeHistory) attempt.Chart.Add(new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." }); foreach (var exam in selected) attempt.Chart.Add(new CcsChartEntry { Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name, SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding }); } else { foreach (var item in request.Commands) { if (item.Type == "order") await ApplyOrderAsync(attempt, definition, item.Code, now, false); else CancelOrder(attempt, item.Code, now); } ProcessDue(attempt); } attempt.ProcessedCommandIds.Add(request.CommandId); if (attempt.ProcessedCommandIds.Count > 100) attempt.ProcessedCommandIds.RemoveRange(0, attempt.ProcessedCommandIds.Count - 100); attempt.Revision++; attempt.UpdatedUtc = now; await SaveAsync(attempt); return attempt; } public async Task HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null) { limit = Math.Clamp(limit, 1, 50); status = status.Trim().ToLowerInvariant(); sort = sort.Trim().ToLowerInvariant(); if (status is not ("all" or "completed" or "abandoned")) throw new ArgumentException("The CCS history status is invalid."); if (sort is not ("newest" or "oldest")) throw new ArgumentException("The CCS history sort order is invalid."); var filters = Builders.Filter; var filter = filters.Eq(value => value.UserId, userId); filter &= status switch { "completed" => filters.Eq(value => value.State, CcsAttemptStates.Completed), "abandoned" => filters.Eq(value => value.State, CcsAttemptStates.Abandoned), _ => filters.In(value => value.State, [CcsAttemptStates.Completed, CcsAttemptStates.Abandoned]) }; if (!string.IsNullOrWhiteSpace(cursor)) { if (!TryDecodeHistoryCursor(cursor, out var decoded)) throw new ArgumentException("The CCS history cursor is invalid."); var dateComparison = sort == "newest" ? filters.Lt(value => value.CreatedUtc, decoded!.CreatedUtc) : filters.Gt(value => value.CreatedUtc, decoded!.CreatedUtc); var idComparison = sort == "newest" ? filters.Lt(value => value.Id, decoded!.Id) : filters.Gt(value => value.Id, decoded!.Id); filter &= dateComparison | (filters.Eq(value => value.CreatedUtc, decoded!.CreatedUtc) & idComparison); } var ordering = sort == "newest" ? Builders.Sort.Descending(value => value.CreatedUtc).Descending(value => value.Id) : Builders.Sort.Ascending(value => value.CreatedUtc).Ascending(value => value.Id); var attempts = await _attempts.Find(filter).Sort(ordering).Limit(limit + 1).ToListAsync(); var hasMore = attempts.Count > limit; if (hasMore) attempts.RemoveAt(attempts.Count - 1); var revisionIds = attempts.Select(value => value.CaseRevisionId).Distinct().ToList(); var definitions = (await _cases.Find(value => revisionIds.Contains(value.Id!)).ToListAsync()).ToDictionary(value => value.Id ?? ""); var items = attempts.Select(value => new CcsHistoryItem { Id = value.Id ?? "", CaseExternalId = value.CaseExternalId, CaseTitle = value.State == CcsAttemptStates.Completed ? definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId : "Timed CCS Case", Mode = value.Mode, State = value.State, Score = value.Score?.Percent, Rating = value.Rating, CreatedUtc = value.CreatedUtc }).ToList(); var last = attempts.LastOrDefault(); return new CcsHistoryPage { Items = items, NextCursor = hasMore && last is not null ? EncodeHistoryCursor(last.CreatedUtc, last.Id ?? "") : null }; } public static string EncodeHistoryCursor(DateTime createdUtc, string id) => Convert.ToBase64String(Encoding.UTF8.GetBytes($"{createdUtc.ToUniversalTime().Ticks}|{id}")); public static bool TryDecodeHistoryCursor(string value, out HistoryCursor? cursor) { cursor = null; try { var parts = Encoding.UTF8.GetString(Convert.FromBase64String(value)).Split('|', 2); if (parts.Length != 2 || !long.TryParse(parts[0], out var ticks) || !ObjectId.TryParse(parts[1], out _)) return false; cursor = new HistoryCursor(new DateTime(ticks, DateTimeKind.Utc), parts[1]); return true; } catch (Exception exception) when (exception is FormatException or ArgumentOutOfRangeException) { return false; } } public sealed record HistoryCursor(DateTime CreatedUtc, string Id); public async Task 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 ToResponseAsync(CcsAttempt attempt) { var definition = await DefinitionAsync(attempt); var completed = attempt.State == CcsAttemptStates.Completed; List activeCatalog = attempt.ActiveOrderCodes.Count == 0 ? [] : await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync(); var activeOrders = activeCatalog.Select(order => { var orderedMinute = attempt.Actions.LastOrDefault(value => value.Type == "order" && value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase))?.SimulatedMinute ?? 0; var pending = attempt.PendingResults.FirstOrDefault(value => value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase)); var reported = attempt.Chart.LastOrDefault(value => value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase) && value.Category is not CcsChartCategories.Orders); return new CcsActiveOrder { Code = order.Code, Name = order.CanonicalName, Category = order.Category, Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute, ReportMinute = pending is not null ? null : reported?.SimulatedMinute, IsPending = pending is not null, HasResult = reported is not null }; }).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList(); List vitalHistory = attempt.VitalHistory.Count > 0 ? attempt.VitalHistory : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary(attempt.CurrentVitals) }]; CcsInterviewStateResponse? interview = null; var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value => value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase)); if (currentInterviewNode is not null) { interview = new CcsInterviewStateResponse { CurrentNodeId = currentInterviewNode.Id, CurrentNode = currentInterviewNode, VisitedChoiceIds = attempt.InterviewChoiceIds, Completed = attempt.OpeningCompleted ?? false }; } 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, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc, OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location, DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes, ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)), PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter, Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History, HistorySections = definition.InitialChart.HistorySections, Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes, ActiveOrders = activeOrders, VitalHistory = vitalHistory, PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title }).ToList(), AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], Interview = interview, Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null, References = completed ? definition.References : [] }; } public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now) { if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete."); var node = definition.InitialInterview.Nodes.FirstOrDefault(value => value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("The interactive interview is unavailable."); var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("The dialogue choice is not available at the current interview node."); var next = definition.InitialInterview.Nodes.FirstOrDefault(value => value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("The dialogue choice target is unavailable."); attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now)); attempt.InterviewChoiceIds.Add(choice.Id); attempt.InterviewNodeId = next.Id; if (next.IsTerminal) attempt.OpeningCompleted = true; } public async Task AbandonAsync(CcsAttempt attempt) { attempt.State = CcsAttemptStates.Abandoned; attempt.ActiveSlot = null; attempt.UpdatedUtc = DateTime.UtcNow; await SaveAsync(attempt); } private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now, bool processImmediately = true) { code = code.Trim().ToLowerInvariant(); var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code."); 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; var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category); if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0)) attempt.PendingResults.Add(new CcsPendingResult { OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName, DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches }); attempt.Chart.Add(new CcsChartEntry { Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName, SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" }); if (delay == 0 && processImmediately) ProcessDue(attempt); } private static void ApplyExam(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now) { var exam = definition.Exams.FirstOrDefault(value => value.Code.Equals(code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("Unknown examination component."); attempt.Actions.Add(Action("exam", exam.Code, attempt.SimulatedMinute, now)); attempt.Chart.Add(new CcsChartEntry { Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name, SimulatedMinute = attempt.SimulatedMinute, Text = 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 = CcsChartCategories.Orders, Code = code, Title = Humanize(code), SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {Humanize(code)}" }); } private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now) { 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 = CcsChartCategories.Progress, Code = "location", Title = "Location change", SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {Humanize(location)}." }); } private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes) { 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(); var vitalsChanged = false; foreach (var result in due) { if (!string.IsNullOrWhiteSpace(result.Result)) attempt.Chart.Add(new CcsChartEntry { Category = string.IsNullOrWhiteSpace(result.Category) ? CcsChartCategories.OtherTest : result.Category, Code = result.OrderCode, Title = result.Title, SimulatedMinute = attempt.SimulatedMinute, Text = result.Result }); foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value; vitalsChanged |= result.VitalPatches.Count > 0; attempt.PendingResults.Remove(result); } if (vitalsChanged) SnapshotVitals(attempt); } private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition) { 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(); var vitalsChanged = false; foreach (var item in events) { attempt.ProcessedEventIds.Add(item.Id); foreach (var patch in item.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value; vitalsChanged |= item.VitalPatches.Count > 0; if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry { Category = CcsChartCategories.Progress, Code = item.Id, Title = "Clinical update", SimulatedMinute = attempt.SimulatedMinute, Text = item.Message }); if (item.EndCase) EnterFinalOrders(attempt); } if (vitalsChanged) SnapshotVitals(attempt); return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase); } private static void EnterFinalOrders(CcsAttempt attempt) { if (attempt.State != CcsAttemptStates.Active) return; attempt.State = CcsAttemptStates.FinalOrders; if (attempt.HardDeadlineUtc is not null) { attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc); } else { var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow; attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add( remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2)); } attempt.Chart.Add(new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions", SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders." }); } private async Task CompleteAsync(CcsAttempt attempt) { if (attempt.State == CcsAttemptStates.Completed) return; var definition = await DefinitionAsync(attempt); attempt.State = CcsAttemptStates.Completed; attempt.ActiveSlot = null; attempt.CompletedUtc = DateTime.UtcNow; attempt.Score = CalculateScore(attempt, definition); attempt.Rating = await _ratings.ApplyAsync(attempt); attempt.UpdatedUtc = DateTime.UtcNow; await SaveAsync(attempt); } public static CcsCaseScore CalculateScore(CcsAttempt attempt, CcsCaseRevision definition) { var results = new List(); 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, Title = string.IsNullOrWhiteSpace(criterion.Title) ? Humanize(criterion.Id) : criterion.Title, ActionType = criterion.ActionType, ExpectedAction = DescribeExpected(criterion, targets), ObservedAction = action?.Code ?? "Not performed", ObservedMinute = action?.SimulatedMinute, 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 }; } public static int ActivePhaseMinutes(int totalMinutes) => Math.Max(1, totalMinutes - 2); public static DateTime FinalOrdersDeadline(DateTime now, DateTime? hardDeadline) { var proposed = now.AddMinutes(2); return hardDeadline is null || proposed <= hardDeadline ? proposed : hardDeadline.Value; } public async Task DefinitionAsync(CcsAttempt attempt) => await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync() ?? throw new InvalidOperationException("The case revision is unavailable."); private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt); private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now }; private static void SnapshotVitals(CcsAttempt attempt) { if (attempt.VitalHistory.Count > 0 && attempt.VitalHistory[^1].SimulatedMinute == attempt.SimulatedMinute && attempt.VitalHistory[^1].Values.OrderBy(value => value.Key).SequenceEqual(attempt.CurrentVitals.OrderBy(value => value.Key))) return; attempt.VitalHistory.Add(new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary(attempt.CurrentVitals) }); } private static string ResultCategory(string? responseSection, string catalogSection, string catalogCategory) { var explicitSection = string.IsNullOrWhiteSpace(responseSection) ? catalogSection : responseSection; if (!string.IsNullOrWhiteSpace(explicitSection)) return explicitSection; return catalogCategory.ToLowerInvariant() switch { "laboratory" => CcsChartCategories.Lab, "imaging" => CcsChartCategories.Imaging, "medication" or "procedure" or "monitoring" => CcsChartCategories.Treatment, _ => CcsChartCategories.OtherTest }; } private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..])); private static string DescribeExpected(CcsScoringCriterion criterion, List targets) { var text = $"{Humanize(criterion.ActionType)}: {string.Join(" or ", targets.Select(Humanize))}"; if (criterion.EarliestMinute is not null && criterion.LatestMinute is not null) text += $" between minute {criterion.EarliestMinute} and {criterion.LatestMinute}"; else if (criterion.EarliestMinute is not null) text += $" at or after minute {criterion.EarliestMinute}"; else if (criterion.LatestMinute is not null) text += $" by minute {criterion.LatestMinute}"; if (!string.IsNullOrWhiteSpace(criterion.AfterTargetCode)) text += $" after {Humanize(criterion.AfterTargetCode)}"; return text; } 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, InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0 }; internal 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; if (CategorySearchTerms(order.Category).Contains(query, StringComparer.OrdinalIgnoreCase)) return 3; return 1000; } private static IReadOnlyList CategorySearchTerms(string category) => category.Trim().ToLowerInvariant() switch { "laboratory" => ["laboratory", "laboratories", "lab", "labs", "test", "tests", "diagnostic", "diagnostics", "study", "studies"], "diagnostic" => ["diagnostic", "diagnostics", "test", "tests", "study", "studies"], "medication" => ["medication", "medications", "medicine", "medicines", "drug", "drugs", "treatment", "treatments", "therapy", "therapies"], "procedure" => ["procedure", "procedures", "intervention", "interventions", "treatment", "treatments"], "monitoring" => ["monitor", "monitoring", "treatment", "treatments"], "disposition" => ["disposition", "admission", "admit", "transfer", "discharge"], _ => [category.Trim().ToLowerInvariant()] }; }