Refine graphical CCS interview flow
This commit is contained in:
@@ -100,7 +100,9 @@ public sealed class CcsCaseTests
|
||||
Assert.NotNull(request);
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
|
||||
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
|
||||
Assert.Equal(4, request.Cases.Single().InitialInterview.Topics.Count(value => value.Required));
|
||||
var topics = request.Cases.Single().InitialInterview.Topics;
|
||||
Assert.Equal(["chief_concern", "dizziness_character", "fatigue_course"], topics.Select(value => value.Id));
|
||||
Assert.Equal(2, topics.Count(value => value.Required));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -56,60 +56,17 @@
|
||||
{
|
||||
"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.",
|
||||
"patientText": "My energy is down by the afternoon, even on days when I sleep normally. I have not noticed fever, shortness of breath, chest pain, or a major change in my appetite.",
|
||||
"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.",
|
||||
"likelyDiagnosis": "Lightheadedness and fatigue requiring further evaluation",
|
||||
"summary": "The focused opening interview identifies intermittent orthostatic lightheadedness without vertigo or syncope and several weeks of nonspecific fatigue. Additional clinical assessment would be needed before assigning a diagnosis.",
|
||||
"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."
|
||||
"Characterize the timing and functional impact of fatigue and ask about sleep, intake, and associated systemic symptoms."
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -120,13 +77,7 @@
|
||||
"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." }
|
||||
{ "id": "explore_fatigue", "title": "Explore fatigue, sleep, and intake", "actionType": "dialogue_choice", "targetCode": "fatigue_course", "points": 10, "rationale": "The course and associated symptoms help characterize the nonspecific fatigue." }
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -111,7 +111,8 @@ anchor_right = 0.53
|
||||
anchor_bottom = 0.04
|
||||
offset_right = 650.0
|
||||
offset_bottom = 220.0
|
||||
mouse_filter = 2
|
||||
mouse_default_cursor_shape = 2
|
||||
focus_mode = 2
|
||||
|
||||
[node name="Background" type="TextureRect" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
@@ -134,15 +135,35 @@ anchor_bottom = 1.0
|
||||
offset_left = 76.0
|
||||
offset_top = 31.0
|
||||
offset_right = -48.0
|
||||
offset_bottom = -31.0
|
||||
offset_bottom = -52.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Hello, doctor."
|
||||
autowrap_mode = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="ContinueHint" type="Label" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -255.0
|
||||
offset_top = -54.0
|
||||
offset_right = -52.0
|
||||
offset_bottom = -25.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.12, 0.22, 0.28, 0.82)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "Click to continue ▼"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="DialogueChoiceArea" type="Control" parent="."]
|
||||
z_index = 29
|
||||
layout_mode = 1
|
||||
|
||||
@@ -10,7 +10,8 @@ const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-c
|
||||
const CHOICE_PRESSED := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-pressed.png")
|
||||
const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0)
|
||||
const CHOICES_PER_PAGE := 4
|
||||
const RESPONSE_HOLD_SECONDS := 1.25
|
||||
const DIALOGUE_PAGE_MIN_CHARS := 32
|
||||
const DIALOGUE_PAGE_MAX_CHARS := 82
|
||||
|
||||
@onready var hour_hand: Node2D = $ClockMount/HourHand
|
||||
@onready var minute_hand: Node2D = $ClockMount/MinuteHand
|
||||
@@ -18,6 +19,7 @@ const RESPONSE_HOLD_SECONDS := 1.25
|
||||
@onready var patient: TextureRect = $Patient
|
||||
@onready var dialogue_bubble: Control = $DialogueBubble
|
||||
@onready var dialogue_text: Label = $DialogueBubble/Text
|
||||
@onready var dialogue_continue_hint: Label = $DialogueBubble/ContinueHint
|
||||
@onready var choice_area: Control = $DialogueChoiceArea
|
||||
@onready var choice_container: HBoxContainer = $DialogueChoiceArea/DialogueChoices
|
||||
@onready var choice_pager: HBoxContainer = $DialogueChoiceArea/ChoicePager
|
||||
@@ -27,16 +29,20 @@ const RESPONSE_HOLD_SECONDS := 1.25
|
||||
|
||||
var blink_tween: Tween
|
||||
var dialogue_tween: Tween
|
||||
var response_hold_tween: Tween
|
||||
var clock_seconds := 9.0 * 60.0 * 60.0
|
||||
var debrief_panel: PanelContainer
|
||||
var experience_mode := "management"
|
||||
var pending_choices: Array = []
|
||||
var available_choices: Array = []
|
||||
var choice_page := 0
|
||||
var dialogue_pages: Array[String] = []
|
||||
var dialogue_page_index := 0
|
||||
var dialogue_characters_per_second := 35.0
|
||||
var dialogue_reveal_complete := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
dialogue_bubble.gui_input.connect(_on_dialogue_bubble_input)
|
||||
previous_page_button.pressed.connect(_change_choice_page.bind(-1))
|
||||
next_page_button.pressed.connect(_change_choice_page.bind(1))
|
||||
_update_clock()
|
||||
@@ -95,13 +101,25 @@ func _set_patient_blink(amount: float) -> void:
|
||||
func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> void:
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
if response_hold_tween and response_hold_tween.is_valid():
|
||||
response_hold_tween.kill()
|
||||
dialogue_text.text = text
|
||||
dialogue_pages = _split_dialogue_pages(text)
|
||||
dialogue_page_index = 0
|
||||
dialogue_characters_per_second = characters_per_second
|
||||
_show_dialogue_page()
|
||||
|
||||
|
||||
func _show_dialogue_page() -> void:
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
if dialogue_pages.is_empty():
|
||||
complete_response_transition()
|
||||
return
|
||||
dialogue_text.text = dialogue_pages[dialogue_page_index]
|
||||
dialogue_text.visible_ratio = 0.0
|
||||
dialogue_reveal_complete = false
|
||||
dialogue_continue_hint.visible = false
|
||||
dialogue_bubble.visible = true
|
||||
call_deferred("_resize_dialogue_bubble")
|
||||
var reveal_duration := float(text.length()) / maxf(characters_per_second, 1.0)
|
||||
dialogue_bubble.grab_focus()
|
||||
var reveal_duration := float(dialogue_text.text.length()) / maxf(dialogue_characters_per_second, 1.0)
|
||||
dialogue_tween = create_tween()
|
||||
dialogue_tween.tween_property(dialogue_text, "visible_ratio", 1.0, reveal_duration)
|
||||
dialogue_tween.finished.connect(_on_dialogue_reveal_finished)
|
||||
@@ -110,9 +128,11 @@ func show_patient_dialogue(text: String, characters_per_second: float = 35.0) ->
|
||||
func hide_patient_dialogue() -> void:
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
if response_hold_tween and response_hold_tween.is_valid():
|
||||
response_hold_tween.kill()
|
||||
dialogue_bubble.visible = false
|
||||
dialogue_continue_hint.visible = false
|
||||
dialogue_pages = []
|
||||
dialogue_page_index = 0
|
||||
dialogue_reveal_complete = false
|
||||
pending_choices = []
|
||||
|
||||
|
||||
@@ -120,12 +140,6 @@ func is_patient_dialogue_visible() -> bool:
|
||||
return dialogue_bubble.visible
|
||||
|
||||
|
||||
func _resize_dialogue_bubble() -> void:
|
||||
var line_count := maxi(dialogue_text.get_line_count(), 1)
|
||||
var target_height := clampf(82.0 + line_count * 29.0, 150.0, 310.0)
|
||||
dialogue_bubble.size.y = target_height
|
||||
|
||||
|
||||
func configure_interview(node: Dictionary) -> void:
|
||||
if debrief_panel != null and is_instance_valid(debrief_panel):
|
||||
debrief_panel.queue_free()
|
||||
@@ -148,6 +162,8 @@ func configure_interview(node: Dictionary) -> void:
|
||||
|
||||
|
||||
func complete_dialogue_reveal() -> void:
|
||||
if dialogue_reveal_complete:
|
||||
return
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
dialogue_text.visible_ratio = 1.0
|
||||
@@ -156,23 +172,77 @@ func complete_dialogue_reveal() -> void:
|
||||
|
||||
func _on_dialogue_reveal_finished() -> void:
|
||||
dialogue_tween = null
|
||||
if response_hold_tween and response_hold_tween.is_valid():
|
||||
response_hold_tween.kill()
|
||||
response_hold_tween = create_tween()
|
||||
response_hold_tween.tween_interval(RESPONSE_HOLD_SECONDS)
|
||||
response_hold_tween.tween_callback(complete_response_transition)
|
||||
dialogue_reveal_complete = true
|
||||
dialogue_continue_hint.text = (
|
||||
"Click to see questions ▼"
|
||||
if dialogue_page_index >= dialogue_pages.size() - 1
|
||||
else "Click to continue ▼"
|
||||
)
|
||||
dialogue_continue_hint.visible = true
|
||||
|
||||
|
||||
func advance_dialogue() -> void:
|
||||
if not dialogue_bubble.visible:
|
||||
return
|
||||
if not dialogue_reveal_complete:
|
||||
complete_dialogue_reveal()
|
||||
return
|
||||
if dialogue_page_index < dialogue_pages.size() - 1:
|
||||
dialogue_page_index += 1
|
||||
_show_dialogue_page()
|
||||
return
|
||||
complete_response_transition()
|
||||
|
||||
|
||||
func complete_response_transition() -> void:
|
||||
if response_hold_tween and response_hold_tween.is_valid():
|
||||
response_hold_tween.kill()
|
||||
response_hold_tween = null
|
||||
var choices := pending_choices
|
||||
pending_choices = []
|
||||
dialogue_bubble.visible = false
|
||||
dialogue_continue_hint.visible = false
|
||||
dialogue_pages = []
|
||||
dialogue_page_index = 0
|
||||
dialogue_reveal_complete = false
|
||||
show_dialogue_choices(choices)
|
||||
|
||||
|
||||
func _on_dialogue_bubble_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||
advance_dialogue()
|
||||
accept_event()
|
||||
elif event is InputEventKey and event.pressed and not event.echo \
|
||||
and event.keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]:
|
||||
advance_dialogue()
|
||||
accept_event()
|
||||
|
||||
|
||||
func _split_dialogue_pages(text: String) -> Array[String]:
|
||||
var normalized := text.strip_edges()
|
||||
if normalized.is_empty():
|
||||
return []
|
||||
var pages: Array[String] = []
|
||||
var current := ""
|
||||
for word in normalized.split(" ", false):
|
||||
var candidate := word if current.is_empty() else "%s %s" % [current, word]
|
||||
var has_natural_break := current.length() >= DIALOGUE_PAGE_MIN_CHARS \
|
||||
and _ends_dialogue_phrase(word)
|
||||
if candidate.length() > DIALOGUE_PAGE_MAX_CHARS and not current.is_empty():
|
||||
pages.append(current)
|
||||
current = word
|
||||
elif has_natural_break:
|
||||
pages.append(candidate)
|
||||
current = ""
|
||||
else:
|
||||
current = candidate
|
||||
if not current.is_empty():
|
||||
pages.append(current)
|
||||
return pages
|
||||
|
||||
|
||||
func _ends_dialogue_phrase(word: String) -> bool:
|
||||
return word.ends_with(",") or word.ends_with(";") or word.ends_with(":") \
|
||||
or word.ends_with(".") or word.ends_with("?") or word.ends_with("!")
|
||||
|
||||
|
||||
func set_choices_pending(pending: bool) -> void:
|
||||
for child in choice_container.get_children():
|
||||
if child is BaseButton:
|
||||
|
||||
@@ -74,12 +74,22 @@ func _run() -> void:
|
||||
await process_frame
|
||||
_assert(screen.active_scene.is_patient_dialogue_visible(), "a selected topic should display the patient's response")
|
||||
_assert(not screen.active_scene.choice_container.visible, "the next choices should wait until the patient finishes speaking")
|
||||
_assert(_label_content_fits(screen.active_scene.dialogue_text), "the complete patient response should fit inside its speech bubble")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
_assert(not screen.active_scene.choice_container.visible, "the completed response should remain visible during the short reading hold")
|
||||
await create_timer(screen.active_scene.RESPONSE_HOLD_SECONDS + 0.1).timeout
|
||||
_assert(screen.active_scene.dialogue_pages.size() > 1, "long patient responses should split into readable dialogue pages")
|
||||
_assert(_label_content_fits(screen.active_scene.dialogue_text), "the first patient response page should fit inside its speech bubble")
|
||||
screen.active_scene.advance_dialogue()
|
||||
_assert(screen.active_scene.dialogue_page_index == 0, "clicking during typing should complete rather than skip the current page")
|
||||
_assert(screen.active_scene.dialogue_continue_hint.visible, "completed dialogue pages should invite the learner to continue")
|
||||
await create_timer(1.35).timeout
|
||||
_assert(screen.active_scene.is_patient_dialogue_visible(), "patient dialogue should remain until the learner advances it")
|
||||
_assert(not screen.active_scene.choice_container.visible, "questions must not appear on a timer")
|
||||
while screen.active_scene.dialogue_page_index < screen.active_scene.dialogue_pages.size() - 1:
|
||||
screen.active_scene.advance_dialogue()
|
||||
await process_frame
|
||||
_assert(_label_content_fits(screen.active_scene.dialogue_text), "every patient response page should fit inside its speech bubble")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
screen.active_scene.advance_dialogue()
|
||||
await process_frame
|
||||
_assert(not screen.active_scene.is_patient_dialogue_visible(), "questions should replace the patient response after the reading hold")
|
||||
_assert(not screen.active_scene.is_patient_dialogue_visible(), "questions should replace patient dialogue after the final learner click")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the first question page should contain four choices")
|
||||
_assert(screen.active_scene.choice_pager.visible, "eight choices should display paging controls")
|
||||
_assert(screen.active_scene.choice_page == 0, "new question sets should begin on the first page")
|
||||
@@ -100,7 +110,7 @@ func _run() -> void:
|
||||
_assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response")
|
||||
_assert(not screen.active_scene.choice_container.visible, "the terminal action should wait for the closing response")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
screen.active_scene.complete_response_transition()
|
||||
screen.active_scene.advance_dialogue()
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff")
|
||||
_assert(not screen.active_scene.choice_pager.visible, "a single terminal action should not show paging controls")
|
||||
screen.active_scene.interview_continue_requested.emit()
|
||||
|
||||
Reference in New Issue
Block a user