Add graphical duodenal ulcer interview

This commit is contained in:
hz
2026-08-05 23:14:51 -05:00
parent 7709d16018
commit 58dd370149
14 changed files with 768 additions and 29 deletions
@@ -34,6 +34,12 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
errors.Add(prefix + "experienceMode is invalid");
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
@@ -73,6 +79,10 @@ public static class CcsCaseValidator
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
if (criterion.ActionType == "dialogue_choice"
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
}
}
}
@@ -123,6 +133,55 @@ public static class CcsCaseValidator
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
{
if (interview.Topics.Count > 0)
{
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
errors.Add(prefix + "topic interview requires openingPatientText");
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
var topicIds = interview.Topics.Select(value => value.Id).ToList();
if (topicIds.Any(string.IsNullOrWhiteSpace)
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
errors.Add(prefix + "interview topic ids must be present and unique");
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "finish_interview is a reserved interview topic id");
if (!interview.Topics.Any(value => value.Required))
errors.Add(prefix + "topic interview requires at least one required topic");
foreach (var topic in interview.Topics)
{
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
if (string.IsNullOrWhiteSpace(topic.PatientText))
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
foreach (var prerequisite in topic.PrerequisiteTopicIds)
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
}
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool ResolveTopic(string topicId)
{
if (resolving.Contains(topicId))
{
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
return false;
}
if (resolved.Contains(topicId)) return true;
resolving.Add(topicId);
if (topicMap.TryGetValue(topicId, out var topic))
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
ResolveTopic(prerequisite);
resolving.Remove(topicId);
resolved.Add(topicId);
return true;
}
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
return;
}
if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return;
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; }
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class CcsPromotionCommand
{
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
? args[sourceIndex + 1]
: Path.Combine(AppContext.BaseDirectory, "content", "ccs");
var files = File.Exists(source)
? new[] { source }
: Directory.Exists(source)
? Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories).ToArray()
: [];
if (files.Length == 0)
{
await output.WriteLineAsync($"CCS content not found: {source}");
return 2;
}
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
var request = new CcsImportRequest();
foreach (var file in files)
{
var parsed = JsonSerializer.Deserialize<CcsImportRequest>(await File.ReadAllTextAsync(file), json)
?? throw new InvalidDataException($"Could not parse CCS content: {file}");
request.Orders.AddRange(parsed.Orders);
request.Cases.AddRange(parsed.Cases);
}
var duplicateOrders = request.Orders.GroupBy(value => value.Code, StringComparer.OrdinalIgnoreCase)
.Where(value => value.Count() > 1).Select(value => value.Key).ToList();
if (duplicateOrders.Count > 0)
throw new InvalidDataException($"Duplicate CCS order codes: {string.Join(", ", duplicateOrders)}");
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables().Build();
var mongo = new MongoDatabaseProvider(configuration);
var orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
var cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
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)
{
foreach (var error in errors) await output.WriteLineAsync(error);
return 1;
}
await output.WriteLineAsync($"Validated {request.Cases.Count} CCS case(s) and {request.Orders.Count} order(s).");
if (!apply)
{
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
return 0;
}
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;
var unchanged = 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 && CcsStore.CaseContentDocument(current).Equals(CcsStore.CaseContentDocument(incoming)))
{
unchanged++;
continue;
}
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++;
}
await output.WriteLineAsync($"Promotion complete: {created} created, {unchanged} unchanged.");
return 0;
}
}
+103 -11
View File
@@ -50,10 +50,16 @@ public sealed class CcsStore
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var skipped = 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 && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
{
skipped++;
continue;
}
if (current is not null)
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
@@ -63,7 +69,17 @@ public sealed class CcsStore
await _cases.InsertOneAsync(incoming);
created++;
}
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created, CasesSkipped = skipped };
}
internal static BsonDocument CaseContentDocument(CcsCaseRevision value)
{
var document = value.ToBsonDocument();
document.Remove("_id");
document.Remove(nameof(CcsCaseRevision.Version));
document.Remove(nameof(CcsCaseRevision.IsCurrent));
document.Remove(nameof(CcsCaseRevision.CreatedUtc));
return document;
}
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
@@ -109,6 +125,7 @@ public sealed class CcsStore
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
var now = DateTime.UtcNow;
var interview = EffectiveInterview(definition);
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
var attempt = new CcsAttempt
{
UserId = userId,
@@ -118,7 +135,7 @@ public sealed class CcsStore
CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
InterviewNodeId = interview.StartNodeId,
InterviewNodeId = interviewStartNodeId,
Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
@@ -126,9 +143,11 @@ public sealed class CcsStore
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,
DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
Chart =
[
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
@@ -416,9 +435,7 @@ public sealed class CcsStore
? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
CcsInterviewStateResponse? interview = null;
var interviewDefinition = EffectiveInterview(definition);
var currentInterviewNode = interviewDefinition.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
var currentInterviewNode = CurrentInterviewNode(attempt, definition);
if (currentInterviewNode is not null)
{
interview = new CcsInterviewStateResponse
@@ -440,9 +457,13 @@ public sealed class CcsStore
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
ExperienceMode = definition.ExperienceMode, GraphicalPresentation = definition.GraphicalPresentation,
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Appearance = definition.InitialChart.Appearance,
History = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? "" : definition.InitialChart.History,
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? [] : definition.InitialChart.HistorySections,
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
@@ -452,6 +473,8 @@ public sealed class CcsStore
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview,
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? definition.InitialInterview.Debrief : null,
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : []
};
@@ -460,6 +483,11 @@ public sealed class CcsStore
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.");
if (definition.InitialInterview.Topics.Count > 0)
{
ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now);
return;
}
var interview = EffectiveInterview(definition);
var node = interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
@@ -730,9 +758,73 @@ public sealed class CcsStore
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags,
InteractiveInterviewAvailable = EffectiveInterview(value).Nodes.Count > 0
InteractiveInterviewAvailable = value.InitialInterview.Topics.Count > 0 || EffectiveInterview(value).Nodes.Count > 0,
GraphicalPresentationAvailable = !string.IsNullOrWhiteSpace(value.GraphicalPresentation.SceneKey)
&& !string.IsNullOrWhiteSpace(value.GraphicalPresentation.PatientAssetKey),
ExperienceMode = value.ExperienceMode, GraphicalPresentation = value.GraphicalPresentation
};
internal static CcsDialogueNode? CurrentInterviewNode(CcsAttempt attempt, CcsCaseRevision definition)
{
var topicInterview = definition.InitialInterview;
if (topicInterview.Topics.Count > 0)
{
if (attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
return new CcsDialogueNode
{
Id = "complete", PatientText = topicInterview.ClosingPatientText, IsTerminal = true
};
var patientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase)
? topicInterview.OpeningPatientText
: topicInterview.Topics.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))?.PatientText;
if (string.IsNullOrWhiteSpace(patientText)) return null;
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var choices = topicInterview.Topics.Where(value => !visited.Contains(value.Id)
&& value.PrerequisiteTopicIds.All(visited.Contains))
.Select(value => new CcsDialogueChoice
{
Id = value.Id, Text = value.ClinicianText, NextNodeId = value.Id
}).ToList();
if (topicInterview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
choices.Add(new CcsDialogueChoice
{
Id = "finish_interview", Text = topicInterview.FinishChoiceText, NextNodeId = "complete"
});
return new CcsDialogueNode
{
Id = attempt.InterviewNodeId, PatientText = patientText, Choices = choices
};
}
var interview = EffectiveInterview(definition);
return interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
}
private static void ApplyTopicChoice(CcsAttempt attempt, CcsInitialInterview interview, string choiceId, DateTime now)
{
if (choiceId.Equals("finish_interview", StringComparison.OrdinalIgnoreCase))
{
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!interview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
throw new ArgumentException("Complete the core interview topics before finishing.");
attempt.Actions.Add(Action("dialogue_choice", "finish_interview", attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add("finish_interview");
attempt.InterviewNodeId = "complete";
attempt.OpeningCompleted = true;
return;
}
var topic = interview.Topics.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
if (attempt.InterviewChoiceIds.Contains(topic.Id, StringComparer.OrdinalIgnoreCase))
throw new ArgumentException("That interview topic has already been covered.");
if (!topic.PrerequisiteTopicIds.All(value => attempt.InterviewChoiceIds.Contains(value, StringComparer.OrdinalIgnoreCase)))
throw new ArgumentException("That interview topic is not available yet.");
attempt.Actions.Add(Action("dialogue_choice", topic.Id, attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add(topic.Id);
attempt.InterviewNodeId = topic.Id;
}
internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition)
{
if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId)