Connect graphical CCS interviews to cases

This commit is contained in:
hz
2026-08-05 20:14:27 -05:00
parent 31093ab80d
commit af2bbb673d
16 changed files with 547 additions and 28 deletions
+52
View File
@@ -135,6 +135,48 @@ public sealed class CcsCaseTests
Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>())); Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>()));
} }
[Fact]
public void InteractiveInterviewRequiresAReachableAcyclicGraph()
{
var request = ValidImport();
request.Cases[0].InitialInterview = ValidInterview();
Assert.Empty(CcsCaseValidator.ValidateImport(request, new HashSet<string>()));
request.Cases[0].InitialInterview.Nodes[0].Choices[0].NextNodeId = "missing";
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("unknown next node", StringComparison.OrdinalIgnoreCase));
request = ValidImport();
request.Cases[0].InitialInterview = ValidInterview();
request.Cases[0].InitialInterview.Nodes[1].IsTerminal = false;
request.Cases[0].InitialInterview.Nodes[1].Choices = [new() { Id = "loop", Text = "Start over", NextNodeId = "greeting" }];
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("cycles", StringComparison.OrdinalIgnoreCase));
request = ValidImport();
request.Cases[0].InitialInterview = ValidInterview();
request.Cases[0].InitialInterview.Nodes.Add(new() { Id = "orphan", PatientText = "Unreachable", IsTerminal = true });
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("reachable", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void DialogueChoicesPersistAndCompleteWithoutAdvancingSimulatedTime()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = ValidInterview();
var attempt = new CcsAttempt { InterviewNodeId = "greeting", SimulatedMinute = 7, OpeningCompleted = false };
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
Assert.Equal("done", attempt.InterviewNodeId);
Assert.Equal(["ask_reason"], attempt.InterviewChoiceIds);
Assert.Equal(7, attempt.SimulatedMinute);
Assert.True(attempt.OpeningCompleted);
Assert.Contains(attempt.Actions, value => value.Type == "dialogue_choice" && value.Code == "ask_reason");
Assert.Throws<InvalidOperationException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow));
}
[Fact] [Fact]
public void CcsExpectedScoreUsesSeparateUserAndCaseElo() public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
{ {
@@ -201,4 +243,14 @@ public sealed class CcsCaseTests
} }
] ]
}; };
private static CcsInitialInterview ValidInterview() => new()
{
StartNodeId = "greeting",
Nodes =
[
new() { Id = "greeting", PatientText = "Hello, doctor.", Choices = [new() { Id = "ask_reason", Text = "What brings you in?", NextNodeId = "done" }] },
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true }
]
};
} }
+34
View File
@@ -65,6 +65,7 @@ public class CcsCaseRevision
public int Difficulty { get; set; } = 2; public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = []; public List<string> Tags { get; set; } = [];
public CcsInitialChart InitialChart { get; set; } = new(); public CcsInitialChart InitialChart { get; set; } = new();
public CcsInitialInterview InitialInterview { get; set; } = new();
public List<string> AllowedLocations { get; set; } = []; public List<string> AllowedLocations { get; set; } = [];
public List<CcsExamDefinition> Exams { get; set; } = []; public List<CcsExamDefinition> Exams { get; set; } = [];
public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = []; public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = [];
@@ -74,6 +75,27 @@ public class CcsCaseRevision
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
} }
public class CcsInitialInterview
{
public string StartNodeId { get; set; } = "";
public List<CcsDialogueNode> Nodes { get; set; } = [];
}
public class CcsDialogueNode
{
public string Id { get; set; } = "";
public string PatientText { get; set; } = "";
public bool IsTerminal { get; set; }
public List<CcsDialogueChoice> Choices { get; set; } = [];
}
public class CcsDialogueChoice
{
public string Id { get; set; } = "";
public string Text { get; set; } = "";
public string NextNodeId { get; set; } = "";
}
public class CcsInitialChart public class CcsInitialChart
{ {
public string PatientSummary { get; set; } = ""; public string PatientSummary { get; set; } = "";
@@ -148,6 +170,8 @@ public class CcsAttempt
public int Revision { get; set; } public int Revision { get; set; }
public int SimulatedMinute { get; set; } public int SimulatedMinute { get; set; }
[BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; } [BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; }
public string InterviewNodeId { get; set; } = "";
public List<string> InterviewChoiceIds { get; set; } = [];
public string Location { get; set; } = ""; public string Location { get; set; } = "";
public Dictionary<string, string> CurrentVitals { get; set; } = []; public Dictionary<string, string> CurrentVitals { get; set; } = [];
public List<CcsAttemptAction> Actions { get; set; } = []; public List<CcsAttemptAction> Actions { get; set; } = [];
@@ -287,6 +311,7 @@ public class CcsCaseSummary
public int RealTimeMinutes { get; set; } public int RealTimeMinutes { get; set; }
public int Difficulty { get; set; } public int Difficulty { get; set; }
public List<string> Tags { get; set; } = []; public List<string> Tags { get; set; } = [];
public bool InteractiveInterviewAvailable { get; set; }
} }
public class CcsAttemptResponse public class CcsAttemptResponse
@@ -320,11 +345,20 @@ public class CcsAttemptResponse
public List<CcsPendingResult> PendingResults { get; set; } = []; public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsExamDefinition> AvailableExams { get; set; } = []; public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = []; public List<string> AllowedLocations { get; set; } = [];
public CcsInterviewStateResponse? Interview { get; set; }
public CcsCaseScore? Score { get; set; } public CcsCaseScore? Score { get; set; }
public CcsRatingChange? Rating { get; set; } public CcsRatingChange? Rating { get; set; }
public List<QuestionReference> References { get; set; } = []; public List<QuestionReference> References { get; set; } = [];
} }
public class CcsInterviewStateResponse
{
public string CurrentNodeId { get; set; } = "";
public CcsDialogueNode CurrentNode { get; set; } = new();
public List<string> VisitedChoiceIds { get; set; } = [];
public bool Completed { get; set; }
}
public class CcsHistoryItem public class CcsHistoryItem
{ {
public string Id { get; set; } = ""; public string Id { get; set; } = "";
+46 -2
View File
@@ -4,7 +4,7 @@ namespace QuestionsApi.Services;
public static class CcsCaseValidator public static class CcsCaseValidator
{ {
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"]; private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "dialogue_choice", "end_case", "exit_case"];
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes) public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{ {
@@ -38,6 +38,7 @@ public static class CcsCaseValidator
errors.Add(prefix + "initial patient summary and reason are required"); errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase)) if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "allowedLocations must include presentingLocation"); errors.Add(prefix + "allowedLocations must include presentingLocation");
ValidateInterview(item.InitialInterview, prefix, errors);
if (item.IsPublished) if (item.IsPublished)
{ {
var referenceError = QuestionValidator.ValidateReferences(item.References, true); var referenceError = QuestionValidator.ValidateReferences(item.References, true);
@@ -90,7 +91,7 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required"; if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative"; if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (!CommandTypes.Contains(request.Type)) return "unsupported command type"; if (!CommandTypes.Contains(request.Type)) return "unsupported command type";
if ((request.Type is "exam" or "order" or "cancel_order" or "location") && string.IsNullOrWhiteSpace(request.Code)) return "code required"; if ((request.Type is "exam" or "order" or "cancel_order" or "location" or "dialogue_choice") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600"; if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
return null; return null;
} }
@@ -119,4 +120,47 @@ public static class CcsCaseValidator
return "history and examinations cannot be mixed with order changes in one batch"; return "history and examinations cannot be mixed with order changes in one batch";
return null; return null;
} }
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
{
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; }
var nodeIds = interview.Nodes.Select(value => value.Id).ToList();
if (nodeIds.Any(string.IsNullOrWhiteSpace) || nodeIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != nodeIds.Count)
errors.Add(prefix + "interview node ids must be present and unique");
var nodeMap = interview.Nodes.Where(value => !string.IsNullOrWhiteSpace(value.Id))
.GroupBy(value => value.Id, StringComparer.OrdinalIgnoreCase).ToDictionary(value => value.Key, value => value.First(), StringComparer.OrdinalIgnoreCase);
if (!nodeMap.ContainsKey(interview.StartNodeId)) errors.Add(prefix + "interview start node does not exist");
var choiceIds = interview.Nodes.SelectMany(value => value.Choices).Select(value => value.Id).ToList();
if (choiceIds.Any(string.IsNullOrWhiteSpace) || choiceIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != choiceIds.Count)
errors.Add(prefix + "interview choice ids must be present and globally unique");
foreach (var node in interview.Nodes)
{
if (string.IsNullOrWhiteSpace(node.PatientText)) errors.Add(prefix + $"interview node {node.Id} requires patientText");
if (node.IsTerminal && node.Choices.Count > 0) errors.Add(prefix + $"terminal interview node {node.Id} cannot have choices");
if (!node.IsTerminal && node.Choices.Count == 0) errors.Add(prefix + $"nonterminal interview node {node.Id} requires choices");
foreach (var choice in node.Choices)
{
if (string.IsNullOrWhiteSpace(choice.Text)) errors.Add(prefix + $"interview choice {choice.Id} requires text");
if (string.IsNullOrWhiteSpace(choice.NextNodeId) || !nodeMap.ContainsKey(choice.NextNodeId))
errors.Add(prefix + $"interview choice {choice.Id} references an unknown next node");
}
}
if (!nodeMap.ContainsKey(interview.StartNodeId)) return;
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var visiting = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool Walk(string nodeId)
{
if (visiting.Contains(nodeId)) { errors.Add(prefix + "interview graph cannot contain cycles"); return false; }
if (visited.Contains(nodeId)) return true;
visiting.Add(nodeId);
if (nodeMap.TryGetValue(nodeId, out var node))
foreach (var choice in node.Choices.Where(value => nodeMap.ContainsKey(value.NextNodeId))) Walk(choice.NextNodeId);
visiting.Remove(nodeId); visited.Add(nodeId); return true;
}
Walk(interview.StartNodeId);
if (visited.Count != nodeMap.Count) errors.Add(prefix + "every interview node must be reachable from startNodeId");
if (!interview.Nodes.Any(value => value.IsTerminal)) errors.Add(prefix + "interview requires a terminal node");
}
} }
+35 -1
View File
@@ -117,6 +117,7 @@ public sealed class CcsStore
CaseVersion = definition.Version, CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed, Mode = CcsAttemptModes.Timed,
OpeningCompleted = false, OpeningCompleted = false,
InterviewNodeId = definition.InitialInterview.StartNodeId,
Location = definition.PresentingLocation, Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals), CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory = VitalHistory =
@@ -227,6 +228,7 @@ public sealed class CcsStore
case "acknowledge_opening": case "acknowledge_opening":
attempt.OpeningCompleted = true; attempt.OpeningCompleted = true;
break; break;
case "dialogue_choice": ApplyDialogueChoice(attempt, definition, command.Code, now); break;
case "end_case": EnterFinalOrders(attempt); break; case "end_case": EnterFinalOrders(attempt); break;
case "exit_case": await CompleteAsync(attempt); break; case "exit_case": await CompleteAsync(attempt); break;
} }
@@ -412,6 +414,19 @@ public sealed class CcsStore
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0 List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
? attempt.VitalHistory ? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }]; : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
CcsInterviewStateResponse? interview = null;
var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
if (currentInterviewNode is not null)
{
interview = new CcsInterviewStateResponse
{
CurrentNodeId = currentInterviewNode.Id,
CurrentNode = currentInterviewNode,
VisitedChoiceIds = attempt.InterviewChoiceIds,
Completed = attempt.OpeningCompleted ?? false
};
}
return new CcsAttemptResponse return new CcsAttemptResponse
{ {
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId, Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
@@ -434,11 +449,29 @@ public sealed class CcsStore
}).ToList(), }).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [], AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview,
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null, Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : [] References = completed ? definition.References : []
}; };
} }
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 =>
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 =>
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));
attempt.InterviewChoiceIds.Add(choice.Id);
attempt.InterviewNodeId = next.Id;
if (next.IsTerminal) attempt.OpeningCompleted = true;
}
public async Task AbandonAsync(CcsAttempt attempt) public async Task AbandonAsync(CcsAttempt attempt)
{ {
attempt.State = CcsAttemptStates.Abandoned; attempt.State = CcsAttemptStates.Abandoned;
@@ -693,7 +726,8 @@ public sealed class CcsStore
{ {
ExternalId = value.ExternalId, Title = value.Title, System = value.System, ExternalId = value.ExternalId, Title = value.Title, System = value.System,
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes, PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags Difficulty = value.Difficulty, Tags = value.Tags,
InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0
}; };
internal static int Rank(CcsOrderDefinition order, string query) internal static int Rank(CcsOrderDefinition order, string query)
{ {
+55
View File
@@ -47,6 +47,61 @@
"simulatedStartUtc": "2026-07-15T08:00:00Z", "simulatedStartUtc": "2026-07-15T08:00:00Z",
"initialOrderCodes": [] "initialOrderCodes": []
}, },
"initialInterview": {
"startNodeId": "greeting",
"nodes": [
{
"id": "greeting",
"patientText": "Doctor, I feel awful. I can barely keep my eyes open.",
"isTerminal": false,
"choices": [
{ "id": "ask_chief_concern", "text": "Tell me what brought you in today.", "nextNodeId": "chief_concern" },
{ "id": "ask_current_problem", "text": "What has been happening recently?", "nextNodeId": "chief_concern" }
]
},
{
"id": "chief_concern",
"patientText": "I have been vomiting for two days, my stomach hurts, and I have become more and more tired.",
"isTerminal": false,
"choices": [
{ "id": "ask_insulin_use", "text": "Have you been taking your insulin?", "nextNodeId": "insulin_history" },
{ "id": "ask_diabetes_treatment", "text": "How have you managed your diabetes during this illness?", "nextNodeId": "insulin_history" }
]
},
{
"id": "insulin_history",
"patientText": "I stopped all of my insulin about a day and a half ago because I was not eating.",
"isTerminal": false,
"choices": [
{ "id": "ask_associated_symptoms", "text": "Have you noticed any other symptoms?", "nextNodeId": "associated_symptoms" },
{ "id": "ask_infection_symptoms", "text": "Any fever, cough, urinary symptoms, or diarrhea?", "nextNodeId": "associated_symptoms" }
]
},
{
"id": "associated_symptoms",
"patientText": "No cough, burning with urination, or diarrhea. I do not think I have had a fever.",
"isTerminal": false,
"choices": [
{ "id": "ask_medical_history", "text": "Tell me about your medical history and medications.", "nextNodeId": "background" },
{ "id": "ask_allergies", "text": "Do you have kidney problems, take other medicines, or have medication allergies?", "nextNodeId": "background" }
]
},
{
"id": "background",
"patientText": "I have type 1 diabetes and normally use long-acting and mealtime insulin. I have no kidney disease and no known drug allergies.",
"isTerminal": false,
"choices": [
{ "id": "finish_interview", "text": "Thank you. I am going to examine you and begin your evaluation.", "nextNodeId": "interview_complete" }
]
},
{
"id": "interview_complete",
"patientText": "Okay, doctor.",
"isTerminal": true,
"choices": []
}
]
},
"allowedLocations": ["emergency_department", "icu", "ward"], "allowedLocations": ["emergency_department", "icu", "ward"],
"exams": [ "exams": [
{ "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." }, { "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." },
+3
View File
@@ -7,6 +7,7 @@ Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validate
## Case structure ## 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. - `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.
- `initialInterview` optionally defines the graphical office interview. It contains a `startNodeId` and an acyclic set of dialogue nodes. Each node has stable `id`, authored `patientText`, `isTerminal`, and clinician `choices` with globally unique IDs and valid `nextNodeId` references. Every branch must reach a terminal node.
- `allowedLocations` controls patient movement. - `allowedLocations` controls patient movement.
- `exams` defines selectable examination components and their simulated duration. - `exams` defines selectable examination components and their simulated duration.
- Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions. - Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions.
@@ -23,4 +24,6 @@ Valid result sections are `lab`, `imaging`, `other_test`, and `treatment`; progr
New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it. New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it.
Graphical interview choices are persisted as attempt actions and support reconnect/resume. They consume real deadline time but do not advance simulated time or contribute directly to scoring. Cases without `initialInterview` remain available in classic CCS and are omitted from CCS Cases 2.
Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula. Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula.
+6
View File
@@ -34,6 +34,7 @@ var queue: Array = []
var text_scale := 1.0 var text_scale := 1.0
var highlights := {} var highlights := {}
var request_access_token := "" var request_access_token := ""
var launch_origin := "ccs"
func _ready() -> void: func _ready() -> void:
@@ -77,6 +78,10 @@ func send_command(type: String, code: String = "", minutes: int = 0) -> void:
"minutes": minutes "minutes": minutes
}) })
func send_dialogue_choice(choice_id: String) -> void:
send_command("dialogue_choice", choice_id)
func send_batch(commands: Array) -> void: func send_batch(commands: Array) -> void:
if attempt.is_empty() or commands.is_empty(): if attempt.is_empty() or commands.is_empty():
return return
@@ -272,6 +277,7 @@ func _escape_bbcode(value: String) -> String:
func _on_auth_changed() -> void: func _on_auth_changed() -> void:
if not AuthState.is_logged_in: if not AuthState.is_logged_in:
launch_origin = "ccs"
highlights.clear() highlights.clear()
text_scale = 1.0 text_scale = 1.0
attempt = {} attempt = {}
+28 -12
View File
@@ -1,6 +1,7 @@
extends Control extends Control
signal dialogue_choice_selected(index: int, text: String) signal choice_selected(choice_id: String)
signal interview_continue_requested
const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png") 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") const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png")
@@ -22,11 +23,8 @@ var dialogue_tween: Tween
func _ready() -> void: func _ready() -> void:
_update_clock() _update_clock()
_schedule_patient_blink() _schedule_patient_blink()
show_patient_dialogue("Hello, doctor.") show_patient_dialogue("Loading patient interview...")
show_dialogue_choices([ hide_dialogue_choices()
"What brings you in today?",
"How are you feeling today?",
])
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
@@ -91,12 +89,28 @@ func _resize_dialogue_bubble() -> void:
dialogue_bubble.size.y = target_height dialogue_bubble.size.y = target_height
func show_dialogue_choices(options: Array[String]) -> void: func configure_interview(node: Dictionary) -> void:
show_patient_dialogue(str(node.get("patientText", "")))
if bool(node.get("isTerminal", false)):
show_dialogue_choices([{"id": "__continue", "text": "Continue to Patient Management"}])
else:
show_dialogue_choices(node.get("choices", []))
func set_choices_pending(pending: bool) -> void:
for child in choice_container.get_children():
if child is BaseButton:
child.disabled = pending
func show_dialogue_choices(options: Array) -> void:
hide_dialogue_choices() hide_dialogue_choices()
var stack_height := options.size() * 97.0 + 8.0 var stack_height := options.size() * 97.0 + 8.0
choice_container.offset_top = -4.0 - stack_height choice_container.offset_top = -4.0 - stack_height
for index in options.size(): for index in options.size():
var option_text := options[index] var option: Dictionary = options[index]
var choice_id := str(option.get("id", ""))
var option_text := str(option.get("text", ""))
var button := TextureButton.new() var button := TextureButton.new()
button.custom_minimum_size = Vector2(420.0, 105.0) button.custom_minimum_size = Vector2(420.0, 105.0)
button.ignore_texture_size = true button.ignore_texture_size = true
@@ -111,7 +125,7 @@ func show_dialogue_choices(options: Array[String]) -> void:
button.mouse_exited.connect(_on_choice_hover.bind(button, false)) button.mouse_exited.connect(_on_choice_hover.bind(button, false))
button.button_down.connect(_on_choice_pressed_visual.bind(button, true)) button.button_down.connect(_on_choice_pressed_visual.bind(button, true))
button.button_up.connect(_on_choice_pressed_visual.bind(button, false)) button.button_up.connect(_on_choice_pressed_visual.bind(button, false))
button.pressed.connect(_on_dialogue_choice_pressed.bind(index, option_text)) button.pressed.connect(_on_dialogue_choice_pressed.bind(choice_id))
var label := Label.new() var label := Label.new()
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -156,6 +170,8 @@ func _on_choice_pressed_visual(button: TextureButton, pressed: bool) -> void:
button.position.y += 2.0 if pressed else -2.0 button.position.y += 2.0 if pressed else -2.0
func _on_dialogue_choice_pressed(index: int, option_text: String) -> void: func _on_dialogue_choice_pressed(choice_id: String) -> void:
dialogue_choice_selected.emit(index, option_text) if choice_id == "__continue":
hide_dialogue_choices() interview_continue_requested.emit()
else:
choice_selected.emit(choice_id)
+214 -4
View File
@@ -1,34 +1,232 @@
extends Control extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd")
const Style := preload("res://scripts/ui/ui_style.gd")
const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn") const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn")
const DESIGN_SIZE := Vector2(1500.0, 800.0) const DESIGN_SIZE := Vector2(1500.0, 800.0)
var main
var stage_root: Control var stage_root: Control
var active_scene: Control var active_scene: Control
var selection_view: VBoxContainer
var case_list: VBoxContainer
var status_label: Label
var retry_button: Button
var current_view := "" var current_view := ""
var cases_loaded := false
var active_loaded := false
var pending_action := ""
func _ready() -> void: func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
clip_contents = true clip_contents = true
_build_selection_view()
stage_root = Control.new() stage_root = Control.new()
stage_root.name = "GraphicalCcsStage" stage_root.name = "GraphicalCcsStage"
stage_root.size = DESIGN_SIZE stage_root.size = DESIGN_SIZE
stage_root.visible = false
add_child(stage_root) add_child(stage_root)
_connect_service()
resized.connect(_fit_stage) resized.connect(_fit_stage)
_fit_stage() _fit_stage()
func _connect_service() -> void:
if not CCSService.cases_changed.is_connected(_on_cases_changed):
CCSService.cases_changed.connect(_on_cases_changed)
if not CCSService.attempt_changed.is_connected(_on_attempt_changed):
CCSService.attempt_changed.connect(_on_attempt_changed)
if not CCSService.request_failed.is_connected(_on_request_failed):
CCSService.request_failed.connect(_on_request_failed)
func on_show(_payload: Variant = null) -> void: func on_show(_payload: Variant = null) -> void:
_show_doctor_office() CCSService.launch_origin = "ccs_cases_2"
cases_loaded = false
active_loaded = false
pending_action = "loading"
_show_selection("Loading interactive cases...")
CCSService.load_cases()
CCSService.load_active()
func on_hide() -> void: func on_hide() -> void:
_clear_active_scene() _clear_active_scene()
func _show_doctor_office() -> void: func compatible_cases(cases: Array) -> Array:
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office") return cases.filter(func(item: Variant) -> bool:
return typeof(item) == TYPE_DICTIONARY and bool(item.get("interactiveInterviewAvailable", false)))
func _on_cases_changed() -> void:
cases_loaded = true
_render_state()
func _on_attempt_changed() -> void:
active_loaded = true
pending_action = ""
_render_state()
func _render_state() -> void:
if not visible:
return
if not active_loaded:
return
var attempt: Dictionary = CCSService.attempt
if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]:
_render_attempt(attempt)
elif cases_loaded and active_loaded:
_render_case_selection()
func _render_attempt(attempt: Dictionary) -> void:
if str(attempt.get("state", "")) != "active":
_handoff_to_classic_player()
return
var interview: Dictionary = attempt.get("interview", {})
if interview.is_empty():
_handoff_to_classic_player()
return
var node: Dictionary = interview.get("currentNode", {})
if node.is_empty():
_handoff_to_classic_player()
return
if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
_handoff_to_classic_player()
return
_show_doctor_office(node)
func _build_selection_view() -> void:
selection_view = VBoxContainer.new()
selection_view.name = "CaseSelection"
selection_view.add_theme_constant_override("separation", 16)
add_child(selection_view)
selection_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var heading := Label.new()
heading.text = "Choose an Interactive CCS Case"
Brand.apply_font(heading)
heading.add_theme_font_size_override("font_size", 28)
selection_view.add_child(heading)
var description := Label.new()
description.text = "Begin a timed patient interview, then continue into clinical management."
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Brand.apply_font(description)
description.add_theme_font_size_override("font_size", 16)
selection_view.add_child(description)
status_label = Label.new()
status_label.name = "CaseSelectionStatus"
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Brand.apply_font(status_label)
selection_view.add_child(status_label)
retry_button = Button.new()
retry_button.text = "Retry"
retry_button.visible = false
retry_button.custom_minimum_size = Vector2(120, 42)
retry_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_primary_button(retry_button)
retry_button.pressed.connect(_retry_load)
selection_view.add_child(retry_button)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
selection_view.add_child(scroll)
case_list = VBoxContainer.new()
case_list.name = "InteractiveCaseList"
case_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
case_list.add_theme_constant_override("separation", 10)
scroll.add_child(case_list)
func _show_selection(message: String = "") -> void:
_clear_active_scene()
current_view = "case_selection"
selection_view.visible = true
stage_root.visible = false
status_label.text = message
retry_button.visible = false
func _render_case_selection() -> void:
_show_selection()
_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."
return
status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"]
retry_button.visible = false
for raw_case in available:
var case: Dictionary = raw_case
var button := Button.new()
button.name = "Case%s" % str(case.get("externalId", "")).to_pascal_case()
button.text = "%s\n%s · Difficulty %d · %d minutes" % [
str(case.get("title", "Untitled case")), str(case.get("system", "")),
int(case.get("difficulty", 0)), int(case.get("realTimeMinutes", 0))]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = Vector2(0, 76)
Style.style_answer_button(button)
button.pressed.connect(_start_case.bind(str(case.get("externalId", ""))))
case_list.add_child(button)
func _start_case(case_external_id: String) -> void:
if not pending_action.is_empty() or case_external_id.is_empty():
return
pending_action = "creating"
status_label.text = "Starting case..."
_set_case_buttons_disabled(true)
CCSService.create_attempt(case_external_id, "timed")
func _show_doctor_office(node: 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)
selection_view.visible = false
stage_root.visible = true
active_scene.configure_interview(node)
active_scene.set_choices_pending(not pending_action.is_empty())
func _submit_dialogue_choice(choice_id: String) -> void:
if not pending_action.is_empty() or choice_id.is_empty():
return
pending_action = "dialogue"
active_scene.set_choices_pending(true)
CCSService.send_dialogue_choice(choice_id)
func _handoff_to_classic_player() -> void:
current_view = "handoff"
if main != null:
main.show_screen("ccs_player")
func _on_request_failed(message: String) -> void:
if not visible:
return
var failed_action := pending_action
pending_action = ""
if failed_action == "creating" and message.contains("409"):
status_label.text = "An active case was found. Loading it now..."
CCSService.load_active()
return
if active_scene != null:
active_scene.set_choices_pending(false)
else:
status_label.text = message
retry_button.visible = failed_action == "loading"
_set_case_buttons_disabled(false)
func _retry_load() -> void:
on_show()
func _set_active_scene(scene: Control, view_name: String) -> void: func _set_active_scene(scene: Control, view_name: String) -> void:
@@ -45,7 +243,19 @@ func _clear_active_scene() -> void:
active_scene.get_parent().remove_child(active_scene) active_scene.get_parent().remove_child(active_scene)
active_scene.queue_free() active_scene.queue_free()
active_scene = null active_scene = null
current_view = "" if current_view == "doctor_office":
current_view = ""
func _set_case_buttons_disabled(disabled: bool) -> void:
for child in case_list.get_children():
if child is BaseButton:
child.disabled = disabled
func _clear_children(parent: Node) -> void:
for child in parent.get_children():
child.queue_free()
func _fit_stage() -> void: func _fit_stage() -> void:
+1
View File
@@ -144,6 +144,7 @@ func _connect_services() -> void:
func on_show(_payload: Variant = null) -> void: func on_show(_payload: Variant = null) -> void:
CCSService.launch_origin = "ccs"
pending_action = "" pending_action = ""
pending_attempt_id = "" pending_attempt_id = ""
status_label.text = "Loading case history..." status_label.text = "Loading case history..."
+1 -1
View File
@@ -820,7 +820,7 @@ func _confirm_abandon() -> void:
func _on_abandoned() -> void: func _on_abandoned() -> void:
if leaving: if leaving:
leaving = false leaving = false
main.show_screen("ccs_hub") main.show_screen("ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs_hub")
func _render_chart() -> void: func _render_chart() -> void:
+8 -1
View File
@@ -36,7 +36,7 @@ func _ready() -> void:
report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog) report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog)
var row := HBoxContainer.new(); add_child(row) var row := HBoxContainer.new(); add_child(row)
var cases := Button.new(); cases.text = "Another Case"; Style.style_primary_button(cases); cases.pressed.connect(_another_case); row.add_child(cases) var cases := Button.new(); cases.text = "Another Case"; Style.style_primary_button(cases); cases.pressed.connect(_another_case); row.add_child(cases)
var back := Button.new(); back.text = "Back to CCS Cases"; back.pressed.connect(func(): main.show_screen("ccs_hub")); row.add_child(back) var back := Button.new(); back.text = "Back to CCS Cases"; back.pressed.connect(_back_to_cases); row.add_child(back)
status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status) status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status)
CCSService.request_failed.connect(_request_failed) CCSService.request_failed.connect(_request_failed)
if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed) if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed)
@@ -156,11 +156,18 @@ func _refresh_loaded_attempt() -> void:
if str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt) if str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt)
func _another_case() -> void: func _another_case() -> void:
if CCSService.launch_origin == "ccs_cases_2":
main.show_screen("ccs_cases_2")
return
if starting_case: return if starting_case: return
starting_case = true starting_case = true
status.text = "Loading a random timed case..." status.text = "Loading a random timed case..."
CCSService.create_attempt("", "timed") CCSService.create_attempt("", "timed")
func _back_to_cases() -> void:
main.show_screen("ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs_hub")
func _request_failed(message: String) -> void: func _request_failed(message: String) -> void:
if not visible or not starting_case: return if not visible or not starting_case: return
starting_case = false starting_case = false
+4 -1
View File
@@ -345,6 +345,7 @@ func _build_ui() -> void:
if FeatureFlags.ccs_cases_2_enabled(): if FeatureFlags.ccs_cases_2_enabled():
var ccs_cases_2 := preload("res://scripts/ui/ccs_cases_2_screen.gd").new() var ccs_cases_2 := preload("res://scripts/ui/ccs_cases_2_screen.gd").new()
ccs_cases_2.name = "CcsCases2" ccs_cases_2.name = "CcsCases2"
ccs_cases_2.main = self
screens_container.add_child(ccs_cases_2) screens_container.add_child(ccs_cases_2)
screens["ccs_cases_2"] = ccs_cases_2 screens["ccs_cases_2"] = ccs_cases_2
@@ -501,7 +502,9 @@ func _on_navigation_pressed(key: String) -> void:
"question_bank": show_screen("selection", "question_bank") "question_bank": show_screen("selection", "question_bank")
"dynamic_questions": show_screen("dynamic_questions") "dynamic_questions": show_screen("dynamic_questions")
"exams": show_screen("simulation_history") "exams": show_screen("simulation_history")
"ccs": show_screen("ccs_hub") "ccs":
CCSService.launch_origin = "ccs"
show_screen("ccs_hub")
"ccs_cases_2": show_screen("ccs_cases_2") "ccs_cases_2": show_screen("ccs_cases_2")
"flashcards": show_screen("flashcards_study") "flashcards": show_screen("flashcards_study")
"performance": show_screen("profile") "performance": show_screen("profile")
+1 -1
View File
@@ -18,7 +18,7 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
elif screen_name == "ccs_cases_2": elif screen_name == "ccs_cases_2":
key = "ccs_cases_2" key = "ccs_cases_2"
elif screen_name.begins_with("ccs_"): elif screen_name.begins_with("ccs_"):
key = "ccs" key = "ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs"
elif screen_name.begins_with("flashcards_"): elif screen_name.begins_with("flashcards_"):
key = "flashcards" key = "flashcards"
elif screen_name == "profile": elif screen_name == "profile":
+1
View File
@@ -653,6 +653,7 @@ func _select_question_bank() -> void:
func _select_ccs() -> void: func _select_ccs() -> void:
CCSService.launch_origin = "ccs"
pending_ccs_launch = true pending_ccs_launch = true
ccs_button.disabled = true ccs_button.disabled = true
ccs_status_label.text = "Selecting a timed case..." ccs_status_label.text = "Selecting a timed case..."
+58 -5
View File
@@ -3,6 +3,13 @@ extends SceneTree
var failures := 0 var failures := 0
class ScreenHost extends Control:
var shown_screen := ""
func show_screen(screen_name: String, _payload: Variant = null) -> void:
shown_screen = screen_name
func _initialize() -> void: func _initialize() -> void:
call_deferred("_run") call_deferred("_run")
@@ -20,25 +27,71 @@ func _run() -> void:
await process_frame await process_frame
ProjectSettings.set_setting("step3/deployment_environment", prior_environment) ProjectSettings.set_setting("step3/deployment_environment", prior_environment)
var host := Control.new() var service = root.get_node("CCSService")
var prior_attempt: Dictionary = service.attempt.duplicate(true)
var prior_cases: Array = service.cases.duplicate(true)
var prior_origin: String = service.launch_origin
var host := ScreenHost.new()
host.size = Vector2(1200.0, 700.0) host.size = Vector2(1200.0, 700.0)
root.add_child(host) root.add_child(host)
var screen = load("res://scripts/ui/ccs_cases_2_screen.gd").new() var screen = load("res://scripts/ui/ccs_cases_2_screen.gd").new()
screen.main = host
host.add_child(screen) host.add_child(screen)
await process_frame await process_frame
screen.on_show()
service.cases = [
{"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, "realTimeMinutes": 20, "interactiveInterviewAvailable": true},
{"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false}
]
service.attempt = {}
screen.cases_loaded = true
screen.active_loaded = true
screen._render_state()
await process_frame await process_frame
_assert(screen.current_view == "doctor_office", "CCS Cases 2 should open directly in the doctor office") _assert(screen.current_view == "case_selection", "CCS Cases 2 should begin with compatible case selection")
_assert(screen.active_scene != null, "CCS Cases 2 should instantiate the doctor-office scene") _assert(screen.case_list.get_child_count() == 1, "legacy cases should be excluded from graphical selection")
service.attempt = _sample_attempt(false)
screen._render_state()
await process_frame
_assert(screen.current_view == "doctor_office", "an active interactive attempt should open the doctor office")
_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") _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_attempt(true)
screen._on_attempt_changed()
await process_frame
_assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response")
_assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff")
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")
screen.on_hide() screen.on_hide()
await process_frame await process_frame
_assert(screen.active_scene == null and screen.current_view.is_empty(), "hiding CCS Cases 2 should release the prototype scene") _assert(screen.active_scene == null, "hiding CCS Cases 2 should release the doctor scene")
host.queue_free() host.queue_free()
service.attempt = prior_attempt
service.cases = prior_cases
service.launch_origin = prior_origin
await process_frame await process_frame
quit(1 if failures > 0 else 0) quit(1 if failures > 0 else 0)
func _sample_attempt(completed: bool) -> Dictionary:
var node := {
"id": "done" if completed else "greeting",
"patientText": "Okay, doctor." if completed else "Hello, doctor.",
"isTerminal": completed,
"choices": [] if completed else [{"id": "ask_reason", "text": "What brings you in?", "nextNodeId": "done"}]
}
return {
"id": "attempt-1", "state": "active", "revision": 1,
"openingCompleted": completed,
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
}
func _assert(condition: bool, message: String) -> void: func _assert(condition: bool, message: String) -> void:
if condition: if condition:
return return