720 lines
41 KiB
C#
720 lines
41 KiB
C#
using MongoDB.Bson;
|
|
using MongoDB.Driver;
|
|
using QuestionsApi.Models;
|
|
using System.Text;
|
|
|
|
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;
|
|
private readonly CcsRatingStore _ratings;
|
|
|
|
public CcsStore(MongoDatabaseProvider mongo, ExamSimulationStore examSimulations, CcsRatingStore ratings)
|
|
{
|
|
_cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
|
_orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
|
|
_attempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
|
|
_examSimulations = examSimulations;
|
|
_ratings = ratings;
|
|
_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)));
|
|
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
|
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)));
|
|
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
|
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.ActiveSlot),
|
|
new CreateIndexOptions { Unique = true, Sparse = true, Name = "unique_active_ccs_attempt" }));
|
|
}
|
|
|
|
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);
|
|
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,
|
|
Location = definition.PresentingLocation,
|
|
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
|
VitalHistory =
|
|
[
|
|
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
|
|
],
|
|
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
|
DeadlineUtc = 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<CcsAttempt?> 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<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 = 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<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 = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
|
|
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
|
|
});
|
|
Advance(attempt, definition, 1);
|
|
break;
|
|
case "acknowledge_opening":
|
|
attempt.OpeningCompleted = true;
|
|
break;
|
|
case "end_case": EnterFinalOrders(attempt); break;
|
|
case "exit_case": await CompleteAsync(attempt); break;
|
|
}
|
|
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<CcsAttempt> ApplyBatchCommandAsync(CcsAttempt attempt, CcsBatchCommandRequest request)
|
|
{
|
|
var validation = CcsCaseValidator.ValidateBatch(request);
|
|
if (validation is not null) throw new ArgumentException(validation);
|
|
if (attempt.ProcessedCommandIds.Contains(request.CommandId, StringComparer.Ordinal)) return attempt;
|
|
if (attempt.Revision != request.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale.");
|
|
if (attempt.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned)
|
|
throw new InvalidOperationException("The CCS attempt is closed.");
|
|
if (attempt.State == CcsAttemptStates.FinalOrders && request.Commands.Any(value => value.Type is not ("order" or "cancel_order")))
|
|
throw new InvalidOperationException("Only final orders may be changed now.");
|
|
|
|
var definition = await DefinitionAsync(attempt);
|
|
var now = DateTime.UtcNow;
|
|
if (request.Commands.Any(value => value.Type is "exam" or "interval_history"))
|
|
{
|
|
var selected = request.Commands.Where(item => item.Type == "exam").Select(item =>
|
|
definition.Exams.FirstOrDefault(value => value.Code.Equals(item.Code, StringComparison.OrdinalIgnoreCase))
|
|
?? throw new ArgumentException($"Unknown examination component: {item.Code}.")).ToList();
|
|
var includeHistory = request.Commands.Any(item => item.Type == "interval_history");
|
|
var startingMinute = attempt.SimulatedMinute;
|
|
foreach (var exam in selected)
|
|
attempt.Actions.Add(Action("exam", exam.Code, startingMinute, now));
|
|
if (includeHistory)
|
|
attempt.Actions.Add(Action("interval_history", "interval_history", startingMinute, now));
|
|
Advance(attempt, definition, selected.Sum(value => Math.Max(1, value.DurationMinutes)) + (includeHistory ? 1 : 0));
|
|
if (includeHistory)
|
|
attempt.Chart.Add(new CcsChartEntry
|
|
{
|
|
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
|
|
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
|
|
});
|
|
foreach (var exam in selected)
|
|
attempt.Chart.Add(new CcsChartEntry
|
|
{
|
|
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
|
|
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
|
|
});
|
|
}
|
|
else
|
|
{
|
|
foreach (var item in request.Commands)
|
|
{
|
|
if (item.Type == "order") await ApplyOrderAsync(attempt, definition, item.Code, now, false);
|
|
else CancelOrder(attempt, item.Code, now);
|
|
}
|
|
ProcessDue(attempt);
|
|
}
|
|
|
|
attempt.ProcessedCommandIds.Add(request.CommandId);
|
|
if (attempt.ProcessedCommandIds.Count > 100)
|
|
attempt.ProcessedCommandIds.RemoveRange(0, attempt.ProcessedCommandIds.Count - 100);
|
|
attempt.Revision++;
|
|
attempt.UpdatedUtc = now;
|
|
await SaveAsync(attempt);
|
|
return attempt;
|
|
}
|
|
|
|
public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null)
|
|
{
|
|
limit = Math.Clamp(limit, 1, 50);
|
|
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<CcsAttempt>.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<CcsAttempt>.Sort.Descending(value => value.CreatedUtc).Descending(value => value.Id)
|
|
: Builders<CcsAttempt>.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<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;
|
|
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
|
|
? []
|
|
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
|
|
var activeOrders = activeCatalog.Select(order =>
|
|
{
|
|
var orderedMinute = attempt.Actions.LastOrDefault(value =>
|
|
value.Type == "order" && value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase))?.SimulatedMinute ?? 0;
|
|
var pending = attempt.PendingResults.FirstOrDefault(value =>
|
|
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
|
|
var reported = attempt.Chart.LastOrDefault(value =>
|
|
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
|
|
&& value.Category is not CcsChartCategories.Orders);
|
|
return new CcsActiveOrder
|
|
{
|
|
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
|
|
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
|
|
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
|
|
IsPending = pending is not null, HasResult = reported is not null
|
|
};
|
|
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
|
|
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
|
|
? attempt.VitalHistory
|
|
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
|
|
return new CcsAttemptResponse
|
|
{
|
|
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
|
|
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
|
|
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
|
|
SimulatedMinute = attempt.SimulatedMinute, 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 : [],
|
|
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
|
References = completed ? definition.References : []
|
|
};
|
|
}
|
|
|
|
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<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,
|
|
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<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
|
|
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
|
|
?? throw new InvalidOperationException("The case revision is unavailable.");
|
|
|
|
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt);
|
|
private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
|
|
private static void SnapshotVitals(CcsAttempt attempt)
|
|
{
|
|
if (attempt.VitalHistory.Count > 0
|
|
&& attempt.VitalHistory[^1].SimulatedMinute == attempt.SimulatedMinute
|
|
&& attempt.VitalHistory[^1].Values.OrderBy(value => value.Key).SequenceEqual(attempt.CurrentVitals.OrderBy(value => value.Key)))
|
|
return;
|
|
attempt.VitalHistory.Add(new CcsVitalSnapshot
|
|
{
|
|
SimulatedMinute = attempt.SimulatedMinute,
|
|
Values = new Dictionary<string, string>(attempt.CurrentVitals)
|
|
});
|
|
}
|
|
private static string ResultCategory(string? responseSection, string catalogSection, string catalogCategory)
|
|
{
|
|
var explicitSection = string.IsNullOrWhiteSpace(responseSection) ? catalogSection : responseSection;
|
|
if (!string.IsNullOrWhiteSpace(explicitSection)) return explicitSection;
|
|
return catalogCategory.ToLowerInvariant() switch
|
|
{
|
|
"laboratory" => CcsChartCategories.Lab,
|
|
"imaging" => CcsChartCategories.Imaging,
|
|
"medication" or "procedure" or "monitoring" => CcsChartCategories.Treatment,
|
|
_ => CcsChartCategories.OtherTest
|
|
};
|
|
}
|
|
private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..]));
|
|
private static string DescribeExpected(CcsScoringCriterion criterion, List<string> targets)
|
|
{
|
|
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
|
|
};
|
|
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<string> 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()]
|
|
};
|
|
}
|