From 58dd370149970ec81ea8ac74fcea1d45993ea71d Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 5 Aug 2026 23:14:51 -0500 Subject: [PATCH] Add graphical duodenal ulcer interview --- .gitea/scripts/deploy-environment.sh | 4 + api/QuestionsApi.Tests/CcsCaseTests.cs | 83 ++++++++++ api/QuestionsApi/Dockerfile | 2 + api/QuestionsApi/Models/CcsModels.cs | 42 +++++ api/QuestionsApi/Program.cs | 6 + api/QuestionsApi/Services/CcsCaseValidator.cs | 59 +++++++ .../Services/CcsPromotionCommand.cs | 92 +++++++++++ api/QuestionsApi/Services/CcsStore.cs | 114 +++++++++++-- content/ccs/duodenal-ulcer-office-001.json | 152 ++++++++++++++++++ docs/CCS_CASE_AUTHORING.md | 4 + scenes/ccs_cases_2/doctor_office.tscn | 7 +- scripts/ui/ccs_cases_2/doctor_office.gd | 127 +++++++++++++-- scripts/ui/ccs_cases_2_screen.gd | 56 ++++++- tests/ccs_cases_2_ui_test.gd | 49 +++++- 14 files changed, 768 insertions(+), 29 deletions(-) create mode 100644 api/QuestionsApi/Services/CcsPromotionCommand.cs create mode 100644 content/ccs/duodenal-ulcer-office-001.json diff --git a/.gitea/scripts/deploy-environment.sh b/.gitea/scripts/deploy-environment.sh index 86ddd43..3659e19 100644 --- a/.gitea/scripts/deploy-environment.sh +++ b/.gitea/scripts/deploy-environment.sh @@ -98,6 +98,10 @@ if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then echo "Dynamic question promotion failed after 3 attempts." >&2 exit 1 fi + kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \ + dotnet QuestionsApi.dll promote-ccs-cases \ + --source /app/content/ccs/duodenal-ulcer-office-001.json \ + --apply fi kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 7c9dc77..4518b89 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -91,6 +91,18 @@ public sealed class CcsCaseTests Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12); } + [Fact] + public void DuodenalUlcerInterviewArtifactMatchesTheCaseSchema() + { + var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/duodenal-ulcer-office-001.json")); + var request = JsonSerializer.Deserialize(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(request); + Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet())); + Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode); + Assert.Equal(4, request.Cases.Single().InitialInterview.Topics.Count(value => value.Required)); + } + [Fact] public void PublishedCaseRequiresReferencesAndKnownOrders() { @@ -210,6 +222,58 @@ public sealed class CcsCaseTests Assert.Same(definition.InitialInterview, CcsStore.EffectiveInterview(definition)); } + [Fact] + public void TopicInterviewBranchesAndUnlocksFinishAfterRequiredTopics() + { + var definition = ValidImport().Cases[0]; + definition.InitialInterview = TopicInterview(); + var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = false }; + + Assert.Equal(["chief_concern"], CcsStore.CurrentInterviewNode(attempt, definition)!.Choices.Select(value => value.Id)); + Assert.Throws(() => CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow)); + CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow); + Assert.Equal(["required_history", "optional_history"], + CcsStore.CurrentInterviewNode(attempt, definition)!.Choices.Select(value => value.Id)); + Assert.Throws(() => CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow)); + + CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow); + var finishable = CcsStore.CurrentInterviewNode(attempt, definition)!; + Assert.Contains(finishable.Choices, value => value.Id == "finish_interview"); + Assert.DoesNotContain(finishable.Choices, value => value.Id == "chief_concern"); + Assert.Throws(() => CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow)); + CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow); + Assert.True(attempt.OpeningCompleted); + Assert.True(CcsStore.CurrentInterviewNode(attempt, definition)!.IsTerminal); + } + + [Fact] + public void TopicInterviewRejectsPrerequisiteCycles() + { + var request = ValidImport(); + request.Cases[0].InitialInterview = TopicInterview(); + request.Cases[0].InitialInterview.Topics[0].PrerequisiteTopicIds = ["required_history"]; + + var errors = CcsCaseValidator.ValidateImport(request, new HashSet()); + + Assert.Contains(errors, value => value.Contains("prerequisites cannot contain cycles", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void CaseContentComparisonIgnoresRevisionMetadata() + { + var first = ValidImport().Cases[0]; + var second = ValidImport().Cases[0]; + first.Id = "507f1f77bcf86cd799439011"; + first.Version = 4; + first.IsCurrent = false; + first.CreatedUtc = DateTime.UtcNow.AddDays(-2); + second.InitialChart.SimulatedStartUtc = first.InitialChart.SimulatedStartUtc; + + Assert.Equal(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second)); + second.Title = "Changed title"; + Assert.NotEqual(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second)); + } + [Fact] public void CcsExpectedScoreUsesSeparateUserAndCaseElo() { @@ -286,4 +350,23 @@ public sealed class CcsCaseTests new() { Id = "done", PatientText = "Thank you.", IsTerminal = true } ] }; + + private static CcsInitialInterview TopicInterview() => new() + { + OpeningPatientText = "Good morning.", ClosingPatientText = "Thank you.", FinishChoiceText = "Finish", + Topics = + [ + new() { Id = "chief_concern", ClinicianText = "What brings you in?", PatientText = "I feel tired." }, + new() + { + Id = "required_history", ClinicianText = "Tell me more.", PatientText = "It started recently.", Required = true, + PrerequisiteTopicIds = ["chief_concern"] + }, + new() + { + Id = "optional_history", ClinicianText = "Anything else?", PatientText = "No.", + PrerequisiteTopicIds = ["chief_concern"] + } + ] + }; } diff --git a/api/QuestionsApi/Dockerfile b/api/QuestionsApi/Dockerfile index b35494d..dbbb6fa 100644 --- a/api/QuestionsApi/Dockerfile +++ b/api/QuestionsApi/Dockerfile @@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj" COPY api/QuestionsApi/ api/QuestionsApi/ COPY content/dynamic_questions/ content/dynamic_questions/ +COPY content/ccs/ content/ccs/ WORKDIR /src/api/QuestionsApi RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION} @@ -17,6 +18,7 @@ ARG BUILD_REVISION WORKDIR /app COPY --from=build /app/publish . COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions +COPY --from=build /src/content/ccs ./content/ccs ENV ASPNETCORE_URLS=http://+:5008 \ ASPNETCORE_ENVIRONMENT=Production \ diff --git a/api/QuestionsApi/Models/CcsModels.cs b/api/QuestionsApi/Models/CcsModels.cs index bb62128..c5ecd97 100644 --- a/api/QuestionsApi/Models/CcsModels.cs +++ b/api/QuestionsApi/Models/CcsModels.cs @@ -64,6 +64,8 @@ public class CcsCaseRevision public int RealTimeMinutes { get; set; } = 20; public int Difficulty { get; set; } = 2; public List Tags { get; set; } = []; + public string ExperienceMode { get; set; } = CcsExperienceModes.Management; + public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new(); public CcsInitialChart InitialChart { get; set; } = new(); public CcsInitialInterview InitialInterview { get; set; } = new(); public List AllowedLocations { get; set; } = []; @@ -79,6 +81,39 @@ public class CcsInitialInterview { public string StartNodeId { get; set; } = ""; public List Nodes { get; set; } = []; + public string OpeningPatientText { get; set; } = ""; + public List Topics { get; set; } = []; + public string FinishChoiceText { get; set; } = "Finish the interview"; + public string ClosingPatientText { get; set; } = "Thank you, doctor."; + public CcsInterviewDebrief Debrief { get; set; } = new(); +} + +public class CcsInterviewTopic +{ + public string Id { get; set; } = ""; + public string ClinicianText { get; set; } = ""; + public string PatientText { get; set; } = ""; + public bool Required { get; set; } + public List PrerequisiteTopicIds { get; set; } = []; +} + +public class CcsInterviewDebrief +{ + public string LikelyDiagnosis { get; set; } = ""; + public string Summary { get; set; } = ""; + public List TeachingPoints { get; set; } = []; +} + +public class CcsGraphicalPresentation +{ + public string SceneKey { get; set; } = ""; + public string PatientAssetKey { get; set; } = ""; +} + +public static class CcsExperienceModes +{ + public const string Management = "management"; + public const string GraphicalInterview = "graphical_interview"; } public class CcsDialogueNode @@ -270,6 +305,7 @@ public class CcsImportResponse { public int OrdersUpserted { get; set; } public int CasesCreated { get; set; } + public int CasesSkipped { get; set; } public List Errors { get; set; } = []; } @@ -312,6 +348,9 @@ public class CcsCaseSummary public int Difficulty { get; set; } public List Tags { get; set; } = []; public bool InteractiveInterviewAvailable { get; set; } + public bool GraphicalPresentationAvailable { get; set; } + public string ExperienceMode { get; set; } = CcsExperienceModes.Management; + public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new(); } public class CcsAttemptResponse @@ -332,6 +371,8 @@ public class CcsAttemptResponse public DateTime CreatedUtc { get; set; } public int RealTimeMinutes { get; set; } public int ElapsedSeconds { get; set; } + public string ExperienceMode { get; set; } = CcsExperienceModes.Management; + public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new(); public string PatientSummary { get; set; } = ""; public string ReasonForEncounter { get; set; } = ""; public string Appearance { get; set; } = ""; @@ -346,6 +387,7 @@ public class CcsAttemptResponse public List AvailableExams { get; set; } = []; public List AllowedLocations { get; set; } = []; public CcsInterviewStateResponse? Interview { get; set; } + public CcsInterviewDebrief? InterviewDebrief { get; set; } public CcsCaseScore? Score { get; set; } public CcsRatingChange? Rating { get; set; } public List References { get; set; } = []; diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 27cc489..d1dfdd8 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions") return; } +if (args.FirstOrDefault() == "promote-ccs-cases") +{ + Environment.ExitCode = await CcsPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); + return; +} + if (args.FirstOrDefault() == "export-dynamic-review") { Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); diff --git a/api/QuestionsApi/Services/CcsCaseValidator.cs b/api/QuestionsApi/Services/CcsCaseValidator.cs index 9262247..849acfa 100644 --- a/api/QuestionsApi/Services/CcsCaseValidator.cs +++ b/api/QuestionsApi/Services/CcsCaseValidator.cs @@ -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 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(StringComparer.OrdinalIgnoreCase); + var resolving = new HashSet(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; } diff --git a/api/QuestionsApi/Services/CcsPromotionCommand.cs b/api/QuestionsApi/Services/CcsPromotionCommand.cs new file mode 100644 index 0000000..ec266f3 --- /dev/null +++ b/api/QuestionsApi/Services/CcsPromotionCommand.cs @@ -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 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(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("CcsOrderCatalog"); + var cases = mongo.Database.GetCollection("CcsCaseRevisions"); + 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) + { + 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.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; + } +} diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index 593c60a..2f8664f 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -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.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> 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(definition.InitialChart.Vitals), VitalHistory = @@ -126,9 +143,11 @@ public sealed class CcsStore 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, + 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(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) diff --git a/content/ccs/duodenal-ulcer-office-001.json b/content/ccs/duodenal-ulcer-office-001.json new file mode 100644 index 0000000..0e8690f --- /dev/null +++ b/content/ccs/duodenal-ulcer-office-001.json @@ -0,0 +1,152 @@ +{ + "orders": [], + "cases": [ + { + "externalId": "ccs-gastro-duodenal-ulcer-office-001", + "isPublished": true, + "title": "Office Visit — 44-year-old man", + "system": "Gastrointestinal System", + "presentingLocation": "office", + "realTimeMinutes": 10, + "difficulty": 1, + "tags": ["history taking", "dyspepsia", "duodenal ulcer", "Helicobacter pylori"], + "experienceMode": "graphical_interview", + "graphicalPresentation": { + "sceneKey": "doctor_office", + "patientAssetKey": "patient_44_white_male" + }, + "initialChart": { + "patientSummary": "44-year-old man attending an office visit", + "reasonForEncounter": "The reason for the visit has not yet been elicited.", + "appearance": "He is seated comfortably, attentive, and in no acute distress.", + "history": "Several weeks of intermittent lightheadedness and fatigue accompany sleep-disrupting, gnawing epigastric discomfort that is worse on an empty stomach and temporarily relieved by food or antacids. He has no symptoms of gastrointestinal bleeding or other alarm features.", + "historySections": [ + { "code": "chief_concern", "title": "Chief concern", "text": "Intermittent lightheadedness and fatigue for several weeks." }, + { "code": "abdominal_symptoms", "title": "Abdominal symptoms", "text": "Gnawing epigastric discomfort occurs when his stomach is empty and at night, and improves temporarily after food or antacids." }, + { "code": "alarm_features", "title": "Alarm features", "text": "No melena, hematemesis, persistent vomiting, dysphagia, unintentional weight loss, or syncope." } + ], + "vitals": { + "temperature": "36.8 C (98.2 F)", + "bloodPressure": "122/76 mm Hg", + "pulse": "76/min", + "respirations": "14/min", + "oxygenSaturation": "99% on room air" + }, + "simulatedStartUtc": "2026-08-05T09:00:00Z", + "initialOrderCodes": [] + }, + "initialInterview": { + "openingPatientText": "Good morning, doctor.", + "finishChoiceText": "Thank you. I have enough history for now.", + "closingPatientText": "Thank you, doctor. I hope we can figure out what has been going on.", + "topics": [ + { + "id": "chief_concern", + "clinicianText": "Good morning. What brings you in today?", + "patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.", + "required": false + }, + { + "id": "dizziness_character", + "clinicianText": "Tell me more about the dizziness. Does the room spin, and have you fainted?", + "patientText": "The room does not spin. It is more like I might black out for a second when I get up, but I have never actually fainted. I have had no weakness, numbness, trouble speaking, chest pain, or racing heartbeat.", + "required": true, + "prerequisiteTopicIds": ["chief_concern"] + }, + { + "id": "fatigue_course", + "clinicianText": "How has the fatigue affected you, and how have you been sleeping and eating?", + "patientText": "My energy is down by the afternoon. I have been eating less because my stomach bothers me, and the discomfort sometimes wakes me at night, so I am not sleeping well.", + "required": true, + "prerequisiteTopicIds": ["chief_concern"] + }, + { + "id": "abdominal_symptoms", + "clinicianText": "You mentioned your stomach. Please describe the discomfort and when it occurs.", + "patientText": "It is a dull, gnawing ache high in the middle of my abdomen. It is usually worse late at night or when I have not eaten. A snack or an antacid settles it for a while, but then it comes back.", + "required": true, + "prerequisiteTopicIds": ["chief_concern"] + }, + { + "id": "bleeding_alarm_features", + "clinicianText": "Have you had black stools, blood in vomit or stool, trouble swallowing, persistent vomiting, weight loss, or severe sudden pain?", + "patientText": "No. My stools look normal, I have not vomited blood, swallowing is fine, and my weight has been steady. I have not had severe or sudden pain.", + "required": true, + "prerequisiteTopicIds": ["chief_concern"] + }, + { + "id": "medications", + "clinicianText": "What medicines do you take, including aspirin, ibuprofen, naproxen, or other over-the-counter products?", + "patientText": "I do not take any prescriptions. I use acetaminophen once in a while for a headache, but I do not use aspirin, ibuprofen, or naproxen.", + "required": false, + "prerequisiteTopicIds": ["chief_concern"] + }, + { + "id": "medical_history", + "clinicianText": "Do you have prior stomach problems, other medical conditions, surgeries, or medication allergies?", + "patientText": "I have never been diagnosed with an ulcer and have not had surgery. I am generally healthy and have no known medication allergies.", + "required": false, + "prerequisiteTopicIds": ["chief_concern"] + }, + { + "id": "social_history", + "clinicianText": "Do you smoke, drink alcohol, or use recreational drugs?", + "patientText": "I do not smoke or use drugs. I might have one or two beers on a weekend, but not every week.", + "required": false, + "prerequisiteTopicIds": ["chief_concern"] + }, + { + "id": "additional_review", + "clinicianText": "Any fever, shortness of breath, headaches, bowel changes, or urinary symptoms?", + "patientText": "No fever, shortness of breath, unusual headaches, bowel changes, or urinary trouble.", + "required": false, + "prerequisiteTopicIds": ["chief_concern"] + } + ], + "debrief": { + "likelyDiagnosis": "Probable uncomplicated H. pylori–associated duodenal ulcer", + "summary": "The key pattern is gnawing epigastric discomfort when fasting or at night with temporary relief from food or antacids. The interview found no evidence of active gastrointestinal bleeding or other alarm features. H. pylori infection is a leading cause, but it would require confirmatory testing.", + "teachingPoints": [ + "Clarify whether dizziness is vertigo, presyncope, or disequilibrium and screen for neurologic and cardiac warning symptoms.", + "Ask directly about melena, hematemesis, syncope, weight loss, dysphagia, persistent vomiting, and sudden severe pain.", + "Review NSAID and aspirin exposure even when the history suggests H. pylori infection." + ] + } + }, + "allowedLocations": ["office"], + "exams": [], + "orderResponses": [], + "events": [], + "scoringCriteria": [ + { "id": "elicit_chief_concern", "title": "Elicit the chief concern", "actionType": "dialogue_choice", "targetCode": "chief_concern", "points": 10, "rationale": "An open-ended opening allows the patient to identify lightheadedness and fatigue in his own words." }, + { "id": "characterize_dizziness", "title": "Characterize the dizziness", "actionType": "dialogue_choice", "targetCode": "dizziness_character", "points": 15, "rationale": "Distinguishing presyncope from vertigo and screening for neurologic and cardiac warning symptoms focuses the differential." }, + { "id": "explore_fatigue", "title": "Explore fatigue, sleep, and intake", "actionType": "dialogue_choice", "targetCode": "fatigue_course", "points": 10, "rationale": "Poor intake and sleep disruption provide a coherent context for the nonspecific fatigue." }, + { "id": "characterize_abdominal_pain", "title": "Characterize abdominal symptoms", "actionType": "dialogue_choice", "targetCode": "abdominal_symptoms", "points": 20, "rationale": "Timing, location, quality, and relieving factors establish the ulcer-like dyspepsia pattern." }, + { "id": "screen_alarm_features", "title": "Screen for bleeding and alarm features", "actionType": "dialogue_choice", "targetCode": "bleeding_alarm_features", "points": 20, "rationale": "Bleeding and other alarm features would change the urgency and evaluation." }, + { "id": "review_medications", "title": "Review NSAID and aspirin exposure", "actionType": "dialogue_choice", "targetCode": "medications", "points": 10, "rationale": "Medication exposure is a major alternative cause of peptic ulcer disease." }, + { "id": "review_background", "title": "Review medical history and allergies", "actionType": "dialogue_choice", "targetCode": "medical_history", "points": 5, "rationale": "Background history informs risk and future treatment selection." }, + { "id": "review_social_history", "title": "Review social history", "actionType": "dialogue_choice", "targetCode": "social_history", "points": 5, "rationale": "Tobacco and heavy alcohol exposure can affect symptoms and risk." }, + { "id": "complete_review", "title": "Complete a focused review of systems", "actionType": "dialogue_choice", "targetCode": "additional_review", "points": 5, "rationale": "A focused review checks for competing causes and associated illness." } + ], + "references": [ + { + "title": "Peptic Ulcer Disease", + "publisher": "American College of Gastroenterology", + "url": "https://gi.org/topics/peptic-ulcer-disease/", + "supports": "Typical ulcer symptoms, bleeding warning symptoms, NSAID history, H. pylori evaluation, and endoscopic diagnosis", + "accessedOn": "2026-08-05", + "reviewer": "Question bank editorial review" + }, + { + "title": "ACG Guideline on Treatment of Helicobacter pylori: New Recommendations", + "publisher": "American College of Gastroenterology", + "url": "https://gi.org/journals-publications/ebgi/schoenfeld_sep2024/", + "supports": "H. pylori treatment principles and confirmation of eradication", + "accessedOn": "2026-08-05", + "reviewer": "Question bank editorial review" + } + ] + } + ], + "validateOnly": false +} diff --git a/docs/CCS_CASE_AUTHORING.md b/docs/CCS_CASE_AUTHORING.md index 55bdd30..6f44d23 100644 --- a/docs/CCS_CASE_AUTHORING.md +++ b/docs/CCS_CASE_AUTHORING.md @@ -4,6 +4,10 @@ CCS content is imported through `POST /api/ccs/admin/import` by a `SUPER` user. Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validateOnly` to `true` first, then submit the same payload with `false`. Updating an existing `externalId` creates a new immutable revision; active and completed attempts remain attached to the revision they started with. +Topic-based graphical interviews use `initialInterview.openingPatientText` plus a `topics` array instead of `startNodeId` and `nodes`. Each topic supplies a stable id, clinician prompt, patient response, and optional `required` and `prerequisiteTopicIds` fields. Required topics gate `finish_interview`; visited topics are removed from the available choices. Set `experienceMode` to `graphical_interview` and provide a supported `graphicalPresentation` to complete in CCS Cases 2 with an authored debrief instead of handing off to classic patient management. + +UAT deployment promotes `content/ccs/duodenal-ulcer-office-001.json` through the idempotent `promote-ccs-cases` command. An unchanged case is skipped; changed content creates the next immutable revision. + ## Case structure - `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback. diff --git a/scenes/ccs_cases_2/doctor_office.tscn b/scenes/ccs_cases_2/doctor_office.tscn index 3671630..f97f0f6 100644 --- a/scenes/ccs_cases_2/doctor_office.tscn +++ b/scenes/ccs_cases_2/doctor_office.tscn @@ -143,7 +143,7 @@ text = "Hello, doctor." autowrap_mode = 2 vertical_alignment = 1 -[node name="DialogueChoices" type="VBoxContainer" parent="."] +[node name="DialogueChoices" type="GridContainer" parent="."] z_index = 29 layout_mode = 1 anchors_preset = 0 @@ -151,8 +151,9 @@ anchor_left = 0.5 anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 -offset_left = -210.0 +offset_left = -450.0 offset_top = -206.0 -offset_right = 210.0 +offset_right = 450.0 offset_bottom = -4.0 theme_override_constants/separation = -8 +columns = 2 diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index aa1b890..1be7f1a 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -2,6 +2,8 @@ extends Control signal choice_selected(choice_id: String) signal interview_continue_requested +signal debrief_return_requested +signal debrief_repeat_requested const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png") const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png") @@ -14,10 +16,13 @@ const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0) @onready var patient: TextureRect = $Patient @onready var dialogue_bubble: Control = $DialogueBubble @onready var dialogue_text: Label = $DialogueBubble/Text -@onready var choice_container: VBoxContainer = $DialogueChoices +@onready var choice_container: GridContainer = $DialogueChoices var blink_tween: Tween var dialogue_tween: Tween +var clock_seconds := 9.0 * 60.0 * 60.0 +var debrief_panel: PanelContainer +var experience_mode := "management" func _ready() -> void: @@ -28,20 +33,35 @@ func _ready() -> void: func _process(_delta: float) -> void: - _update_clock() + pass func _update_clock() -> void: - var time := Time.get_time_dict_from_system() - var unix_time := Time.get_unix_time_from_system() - var second := float(time.second) + fmod(unix_time, 1.0) - var minute := float(time.minute) + second / 60.0 - var hour := float(time.hour % 12) + minute / 60.0 + var hour_value := floori(clock_seconds / 3600.0) + var minute_value := floori(fmod(clock_seconds, 3600.0) / 60.0) + var second_value := fmod(clock_seconds, 60.0) + var second := float(second_value) + var minute := float(minute_value) + second / 60.0 + var hour := float(hour_value % 12) + minute / 60.0 hour_hand.rotation = TAU * hour / 12.0 minute_hand.rotation = TAU * minute / 60.0 second_hand.rotation = TAU * second / 60.0 +func configure_presentation(presentation: Dictionary, simulated_start: String, simulated_minute: int) -> void: + if str(presentation.get("patientAssetKey", "")) != "patient_44_white_male": + push_warning("Unsupported CCS patient asset key; using the office default.") + var date_time := Time.get_datetime_dict_from_datetime_string(simulated_start, false) + if not date_time.is_empty(): + clock_seconds = float(int(date_time.get("hour", 9)) * 3600 + int(date_time.get("minute", 0)) * 60) + clock_seconds += float(simulated_minute * 60) + _update_clock() + + +func configure_experience_mode(value: String) -> void: + experience_mode = value + + func _schedule_patient_blink() -> void: blink_tween = create_tween() blink_tween.tween_interval(randf_range(2.8, 5.2)) @@ -90,9 +110,15 @@ func _resize_dialogue_bubble() -> void: func configure_interview(node: Dictionary) -> void: + if debrief_panel != null and is_instance_valid(debrief_panel): + debrief_panel.queue_free() + debrief_panel = null show_patient_dialogue(str(node.get("patientText", ""))) if bool(node.get("isTerminal", false)): - show_dialogue_choices([{"id": "__continue", "text": "Continue to Patient Management"}]) + show_dialogue_choices([{ + "id": "__continue", + "text": "Finish Interview" if experience_mode == "graphical_interview" else "Continue to Patient Management" + }]) else: show_dialogue_choices(node.get("choices", [])) @@ -105,14 +131,15 @@ func set_choices_pending(pending: bool) -> void: func show_dialogue_choices(options: Array) -> void: hide_dialogue_choices() - var stack_height := options.size() * 97.0 + 8.0 + var row_count := ceili(float(options.size()) / 2.0) + var stack_height := row_count * 97.0 + 8.0 choice_container.offset_top = -4.0 - stack_height for index in options.size(): var option: Dictionary = options[index] var choice_id := str(option.get("id", "")) var option_text := str(option.get("text", "")) var button := TextureButton.new() - button.custom_minimum_size = Vector2(420.0, 105.0) + button.custom_minimum_size = Vector2(440.0, 105.0) button.ignore_texture_size = true button.stretch_mode = TextureButton.STRETCH_SCALE button.texture_normal = _crop_choice_texture(CHOICE_NORMAL) @@ -175,3 +202,83 @@ func _on_dialogue_choice_pressed(choice_id: String) -> void: interview_continue_requested.emit() else: choice_selected.emit(choice_id) + + +func show_debrief(debrief: Dictionary, score: Dictionary) -> void: + hide_patient_dialogue() + hide_dialogue_choices() + if debrief_panel != null and is_instance_valid(debrief_panel): + debrief_panel.queue_free() + debrief_panel = PanelContainer.new() + debrief_panel.name = "InterviewDebrief" + debrief_panel.z_index = 50 + debrief_panel.position = Vector2(315.0, 100.0) + debrief_panel.size = Vector2(870.0, 600.0) + var panel_style := StyleBoxFlat.new() + panel_style.bg_color = Color(0.96, 0.95, 0.91, 0.97) + panel_style.border_color = Color(0.12, 0.22, 0.28, 0.8) + panel_style.set_border_width_all(2) + panel_style.set_corner_radius_all(12) + panel_style.set_content_margin_all(28.0) + debrief_panel.add_theme_stylebox_override("panel", panel_style) + add_child(debrief_panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 16) + debrief_panel.add_child(content) + var heading := Label.new() + heading.text = "Interview Debrief" + heading.add_theme_font_size_override("font_size", 30) + heading.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145)) + content.add_child(heading) + var diagnosis := Label.new() + diagnosis.text = str(debrief.get("likelyDiagnosis", "Interview complete")) + diagnosis.add_theme_font_size_override("font_size", 23) + diagnosis.add_theme_color_override("font_color", Color(0.12, 0.36, 0.27)) + diagnosis.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(diagnosis) + var summary := Label.new() + summary.text = str(debrief.get("summary", "")) + summary.add_theme_font_size_override("font_size", 18) + summary.add_theme_color_override("font_color", Color(0.08, 0.12, 0.15)) + summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(summary) + var score_label := Label.new() + score_label.text = "History coverage: %d%%" % int(round(float(score.get("percent", 0.0)))) + score_label.add_theme_font_size_override("font_size", 20) + content.add_child(score_label) + var covered: Array[String] = [] + var missed: Array[String] = [] + for raw_criterion in score.get("criteria", []): + var criterion: Dictionary = raw_criterion + var title := str(criterion.get("title", "History topic")) + if str(criterion.get("status", "")) == "credited": + covered.append(title) + elif str(criterion.get("status", "")) == "missed": + missed.append(title) + var coverage := Label.new() + coverage.text = "Covered: %s\nMissed optional topics: %s" % [ + ", ".join(covered), "None" if missed.is_empty() else ", ".join(missed)] + coverage.add_theme_font_size_override("font_size", 16) + coverage.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(coverage) + var points := Label.new() + var teaching_points: Array = debrief.get("teachingPoints", []) + points.text = "Key points\n- " + "\n- ".join(teaching_points) if not teaching_points.is_empty() else "" + points.add_theme_font_size_override("font_size", 17) + points.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + points.size_flags_vertical = Control.SIZE_EXPAND_FILL + content.add_child(points) + var actions := HBoxContainer.new() + actions.alignment = BoxContainer.ALIGNMENT_END + actions.add_theme_constant_override("separation", 12) + content.add_child(actions) + var return_button := Button.new() + return_button.text = "Return to Cases" + return_button.custom_minimum_size = Vector2(190.0, 48.0) + return_button.pressed.connect(func(): debrief_return_requested.emit()) + actions.add_child(return_button) + var repeat_button := Button.new() + repeat_button.text = "Repeat Interview" + repeat_button.custom_minimum_size = Vector2(190.0, 48.0) + repeat_button.pressed.connect(func(): debrief_repeat_requested.emit()) + actions.add_child(repeat_button) diff --git a/scripts/ui/ccs_cases_2_screen.gd b/scripts/ui/ccs_cases_2_screen.gd index 6ce464a..b4370aa 100644 --- a/scripts/ui/ccs_cases_2_screen.gd +++ b/scripts/ui/ccs_cases_2_screen.gd @@ -57,7 +57,10 @@ func on_hide() -> void: func compatible_cases(cases: Array) -> Array: return cases.filter(func(item: Variant) -> bool: - return typeof(item) == TYPE_DICTIONARY and bool(item.get("interactiveInterviewAvailable", false))) + return typeof(item) == TYPE_DICTIONARY \ + and bool(item.get("interactiveInterviewAvailable", false)) \ + and bool(item.get("graphicalPresentationAvailable", false)) \ + and str(item.get("graphicalPresentation", {}).get("sceneKey", "")) == "doctor_office") func _on_cases_changed() -> void: @@ -77,6 +80,10 @@ func _render_state() -> void: if not active_loaded: return var attempt: Dictionary = CCSService.attempt + if not attempt.is_empty() and str(attempt.get("state", "")) == "completed" \ + and str(attempt.get("experienceMode", "")) == "graphical_interview": + _render_debrief(attempt) + return if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]: _render_attempt(attempt) elif cases_loaded and active_loaded: @@ -98,7 +105,7 @@ func _render_attempt(attempt: Dictionary) -> void: if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)): _handoff_to_classic_player() return - _show_doctor_office(node) + _show_doctor_office(node, attempt) func _build_selection_view() -> void: @@ -156,7 +163,7 @@ func _render_case_selection() -> void: _clear_children(case_list) var available := compatible_cases(CCSService.cases) if available.is_empty(): - status_label.text = "No published CCS cases currently include an interactive interview." + status_label.text = "No published CCS cases currently have a supported graphical interview." return status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"] retry_button.visible = false @@ -183,13 +190,20 @@ func _start_case(case_external_id: String) -> void: CCSService.create_attempt(case_external_id, "timed") -func _show_doctor_office(node: Dictionary) -> void: +func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void: if active_scene == null: _set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office") active_scene.choice_selected.connect(_submit_dialogue_choice) active_scene.interview_continue_requested.connect(_handoff_to_classic_player) + active_scene.debrief_return_requested.connect(_return_to_cases) + active_scene.debrief_repeat_requested.connect(_repeat_interview) selection_view.visible = false stage_root.visible = true + active_scene.configure_experience_mode(str(attempt.get("experienceMode", "management"))) + active_scene.configure_presentation( + attempt.get("graphicalPresentation", {}), + str(attempt.get("simulatedStartUtc", "")), + int(attempt.get("simulatedMinute", 0))) active_scene.configure_interview(node) active_scene.set_choices_pending(not pending_action.is_empty()) @@ -203,11 +217,45 @@ func _submit_dialogue_choice(choice_id: String) -> void: func _handoff_to_classic_player() -> void: + if str(CCSService.attempt.get("experienceMode", "")) == "graphical_interview": + pending_action = "finishing_interview" + active_scene.set_choices_pending(true) + CCSService.send_command("exit_case") + return current_view = "handoff" if main != null: main.show_screen("ccs_player") +func _render_debrief(attempt: Dictionary) -> void: + if active_scene == null: + _set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office") + active_scene.debrief_return_requested.connect(_return_to_cases) + active_scene.debrief_repeat_requested.connect(_repeat_interview) + selection_view.visible = false + stage_root.visible = true + active_scene.configure_presentation( + attempt.get("graphicalPresentation", {}), + str(attempt.get("simulatedStartUtc", "")), + int(attempt.get("simulatedMinute", 0))) + active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {})) + current_view = "interview_debrief" + + +func _return_to_cases() -> void: + CCSService.attempt = {} + pending_action = "" + _render_case_selection() + + +func _repeat_interview() -> void: + var case_external_id := str(CCSService.attempt.get("caseExternalId", "")) + if case_external_id.is_empty() or not pending_action.is_empty(): + return + pending_action = "creating" + CCSService.create_attempt(case_external_id, "timed") + + func _on_request_failed(message: String) -> void: if not visible: return diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 4f02874..e6c4423 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -40,7 +40,11 @@ func _run() -> void: await process_frame service.cases = [ - {"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, "realTimeMinutes": 20, "interactiveInterviewAvailable": true}, + { + "externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, + "realTimeMinutes": 20, "interactiveInterviewAvailable": true, "graphicalPresentationAvailable": true, + "graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"} + }, {"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false} ] service.attempt = {} @@ -58,6 +62,11 @@ func _run() -> void: _assert(screen.active_scene.dialogue_text.text == "Hello, doctor.", "the patient bubble should use the server-provided current node") _assert(screen.active_scene.choice_container.get_child_count() == 1, "the scene should render server-provided dialogue choices") _assert(is_equal_approx(screen.stage_root.scale.x, screen.stage_root.scale.y), "the graphical stage should preserve its aspect ratio") + service.attempt = _sample_branching_attempt() + screen._render_state() + await process_frame + _assert(screen.active_scene.choice_container.columns == 2, "branching interview choices should use two columns") + _assert(screen.active_scene.choice_container.get_child_count() == 8, "all available branching topics should render") service.attempt = _sample_attempt(true) screen._on_attempt_changed() @@ -67,6 +76,12 @@ func _run() -> void: screen.active_scene.interview_continue_requested.emit() _assert(host.shown_screen == "ccs_player", "terminal dialogue should hand off the same attempt to the CCS player") + service.attempt = _sample_graphical_debrief() + screen._on_attempt_changed() + await process_frame + _assert(screen.current_view == "interview_debrief", "a completed graphical interview should remain in CCS Cases 2") + _assert(screen.active_scene.find_child("InterviewDebrief", true, false) != null, "the graphical case should render its debrief") + screen.on_hide() await process_frame _assert(screen.active_scene == null, "hiding CCS Cases 2 should release the doctor scene") @@ -87,11 +102,43 @@ func _sample_attempt(completed: bool) -> Dictionary: } return { "id": "attempt-1", "state": "active", "revision": 1, + "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, + "graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}, "openingCompleted": completed, "interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed} } +func _sample_graphical_debrief() -> Dictionary: + return { + "id": "attempt-2", "caseExternalId": "interactive", "state": "completed", "revision": 7, + "experienceMode": "graphical_interview", "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, + "graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}, + "interviewDebrief": { + "likelyDiagnosis": "Probable duodenal ulcer", "summary": "A focused history supports the assessment.", + "teachingPoints": ["Characterize dizziness.", "Screen for bleeding."] + }, + "score": {"percent": 85.0} + } + + +func _sample_branching_attempt() -> Dictionary: + var choices: Array = [] + for index in 8: + choices.append({"id": "topic_%d" % index, "text": "Ask about history topic %d" % (index + 1), "nextNodeId": "topic_%d" % index}) + return { + "id": "attempt-1", "state": "active", "revision": 2, + "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, + "graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}, + "openingCompleted": false, + "interview": { + "currentNodeId": "chief_concern", + "currentNode": {"id": "chief_concern", "patientText": "I feel tired and lightheaded.", "isTerminal": false, "choices": choices}, + "visitedChoiceIds": ["chief_concern"], "completed": false + } + } + + func _assert(condition: bool, message: String) -> void: if condition: return