Files
step3/api/QuestionsApi/Services/CcsStore.cs
T

402 lines
23 KiB
C#

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
};
}
public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
?? throw new InvalidOperationException("The case revision is unavailable.");
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt);
private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
private static 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;
}
}