From 7709d16018c6492a496dca5f5317f80408a922e5 Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 5 Aug 2026 20:46:55 -0500 Subject: [PATCH] Show legacy CCS cases in interactive scene --- api/QuestionsApi.Tests/CcsCaseTests.cs | 33 ++++++++++++ api/QuestionsApi/Services/CcsStore.cs | 71 ++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 6ee4898..7c9dc77 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -177,6 +177,39 @@ public sealed class CcsCaseTests Assert.Throws(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow)); } + [Fact] + public void LegacyCaseGetsInterviewFromExistingChartNarrative() + { + var definition = ValidImport().Cases[0]; + definition.InitialInterview = new(); + definition.InitialChart.HistorySections = + [ + new() { Code = "hpi", Title = "History of present illness", Text = "The symptoms began this morning." }, + new() { Code = "medications", Title = "Medications", Text = "I take no regular medicines." } + ]; + + var interview = CcsStore.EffectiveInterview(definition); + var attempt = new CcsAttempt { InterviewNodeId = interview.StartNodeId, OpeningCompleted = false }; + + Assert.Equal("opening", interview.StartNodeId); + Assert.Equal(5, interview.Nodes.Count); + CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow); + CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_1", DateTime.UtcNow); + CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_2", DateTime.UtcNow); + CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow); + Assert.Equal("complete", attempt.InterviewNodeId); + Assert.True(attempt.OpeningCompleted); + } + + [Fact] + public void AuthoredInterviewRemainsPreferredOverGeneratedFallback() + { + var definition = ValidImport().Cases[0]; + definition.InitialInterview = ValidInterview(); + + Assert.Same(definition.InitialInterview, CcsStore.EffectiveInterview(definition)); + } + [Fact] public void CcsExpectedScoreUsesSeparateUserAndCaseElo() { diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index 5dd7b31..593c60a 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -108,6 +108,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 attempt = new CcsAttempt { UserId = userId, @@ -117,7 +118,7 @@ public sealed class CcsStore CaseVersion = definition.Version, Mode = CcsAttemptModes.Timed, OpeningCompleted = false, - InterviewNodeId = definition.InitialInterview.StartNodeId, + InterviewNodeId = interview.StartNodeId, Location = definition.PresentingLocation, CurrentVitals = new Dictionary(definition.InitialChart.Vitals), VitalHistory = @@ -415,7 +416,8 @@ public sealed class CcsStore ? attempt.VitalHistory : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary(attempt.CurrentVitals) }]; CcsInterviewStateResponse? interview = null; - var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value => + var interviewDefinition = EffectiveInterview(definition); + var currentInterviewNode = interviewDefinition.Nodes.FirstOrDefault(value => value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase)); if (currentInterviewNode is not null) { @@ -458,12 +460,13 @@ 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."); - var node = definition.InitialInterview.Nodes.FirstOrDefault(value => + var interview = EffectiveInterview(definition); + var node = interview.Nodes.FirstOrDefault(value => value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("The interactive interview is unavailable."); var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("The dialogue choice is not available at the current interview node."); - var next = definition.InitialInterview.Nodes.FirstOrDefault(value => + var next = interview.Nodes.FirstOrDefault(value => value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("The dialogue choice target is unavailable."); attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now)); @@ -727,8 +730,66 @@ 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 = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0 + InteractiveInterviewAvailable = EffectiveInterview(value).Nodes.Count > 0 }; + + internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition) + { + if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId) + && definition.InitialInterview.Nodes.Count > 0) + return definition.InitialInterview; + + var narrative = definition.InitialChart.HistorySections + .Where(value => !string.IsNullOrWhiteSpace(value.Text)) + .Select((value, index) => new + { + Id = $"history_{index + 1}", + Prompt = string.IsNullOrWhiteSpace(value.Title) + ? "Can you tell me more about your history?" + : $"Can you tell me about your {value.Title.Trim().ToLowerInvariant()}?", + value.Text + }).ToList(); + if (narrative.Count == 0 && !string.IsNullOrWhiteSpace(definition.InitialChart.History)) + narrative.Add(new { Id = "history_1", Prompt = "Can you tell me more about your history?", Text = definition.InitialChart.History }); + + var nodes = new List + { + new() + { + Id = "opening", PatientText = "Hello, doctor.", + Choices = [new() { Id = "ask_reason", Text = "What brings you in today?", NextNodeId = "reason" }] + }, + new() + { + Id = "reason", PatientText = definition.InitialChart.ReasonForEncounter, + Choices = [new() + { + Id = narrative.Count > 0 ? "ask_history_1" : "finish_interview", + Text = narrative.Count > 0 ? narrative[0].Prompt : "Is there anything else you would like me to know?", + NextNodeId = narrative.Count > 0 ? narrative[0].Id : "complete" + }] + } + }; + for (var index = 0; index < narrative.Count; index++) + { + var next = index + 1 < narrative.Count ? narrative[index + 1] : null; + nodes.Add(new CcsDialogueNode + { + Id = narrative[index].Id, PatientText = narrative[index].Text, + Choices = [new() + { + Id = next is null ? "finish_interview" : $"ask_history_{index + 2}", + Text = next?.Prompt ?? "Is there anything else you would like me to know?", + NextNodeId = next?.Id ?? "complete" + }] + }); + } + nodes.Add(new CcsDialogueNode + { + Id = "complete", PatientText = "That is everything I can think of, doctor.", IsTerminal = true + }); + return new CcsInitialInterview { StartNodeId = "opening", Nodes = nodes }; + } internal static int Rank(CcsOrderDefinition order, string query) { var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());