Add data-driven dynamic question mode

This commit is contained in:
hz
2026-07-23 12:33:23 -05:00
parent 50f07a77a5
commit fcbc288a49
22 changed files with 1247 additions and 21 deletions
+30
View File
@@ -33,6 +33,36 @@ const CORE_MIN_TARGET_SECONDS := 45.0
const CORE_MAX_TARGET_SECONDS := 150.0
const CORE_RETRY_TARGET_SECONDS := 30.0
func start_dynamic_question(question: Dictionary) -> bool:
if question.is_empty():
return false
system_name = str(question.get("system", "All Systems"))
mode = "Tutor"
session_kind = "dynamic"
active_questions = [_prepare_question_choices(question)]
answers = [null]
current_index = 0
seconds_remaining = 0.0
session_initial_seconds = 90.0
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
block_started = true
session_changed.emit()
return true
func replace_dynamic_question(question: Dictionary) -> bool:
if session_kind != "dynamic" or question.is_empty():
return false
active_questions = [_prepare_question_choices(question)]
answers = [null]
current_index = 0
current_question_started_at = Time.get_ticks_msec() / 1000.0
current_question_pause_started_at = 0.0
session_changed.emit()
return true
func start_block(selected_system: String, block_size: int, selected_mode: String) -> void:
system_name = selected_system
mode = selected_mode
@@ -0,0 +1,108 @@
extends Node
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_API_URL := "https://us3q.ranaze.com/api/dynamic-questions"
signal status_loaded(status: Dictionary)
signal question_loaded(question: Dictionary)
signal attempt_completed(result: Dictionary)
signal report_submitted(question_id: String, report_id: String)
signal report_failed(question_id: String, message: String)
signal request_failed(message: String)
var api_url := DEFAULT_API_URL
var run_token := ""
var generation_token := ""
var current_envelope := {}
var _request: HTTPRequest
var _operation := ""
var _report_question_id := ""
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/dynamic_questions_api_url", DEFAULT_API_URL)),
"/api/dynamic-questions")
_request = HTTPRequest.new()
_request.timeout = 30.0
_request.request_completed.connect(_on_completed)
add_child(_request)
func load_status() -> void:
_send("status", HTTPClient.METHOD_GET, "/status", {})
func load_next() -> void:
_send("next", HTTPClient.METHOD_POST, "/next", {"runToken": run_token})
func submit_attempt(choice_id: String, duration_seconds: int, is_skip := false) -> void:
_send("attempt", HTTPClient.METHOD_POST, "/attempt", {
"generationToken": generation_token,
"choiceId": choice_id,
"isSkip": is_skip,
"durationSeconds": duration_seconds
})
func submit_report(question_id: String, category: String, details: String) -> void:
_report_question_id = question_id
_send("report", HTTPClient.METHOD_POST, "/report", {
"generationToken": generation_token,
"category": category,
"details": details
})
func reset_run() -> void:
run_token = ""
generation_token = ""
current_envelope = {}
func _send(operation: String, method: int, suffix: String, payload: Dictionary) -> void:
if not AuthState.can_send_authenticated_request():
request_failed.emit("Log in to use Dynamic Questions.")
return
if _request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
_operation = operation
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % AuthState.access_token
])
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var error := _request.request(api_url + suffix, headers, method, body)
if error != OK:
_operation = ""
request_failed.emit("Could not connect to Dynamic Questions.")
func _on_completed(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := _operation
_operation = ""
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if response_code < 200 or response_code >= 300 or typeof(parsed) != TYPE_DICTIONARY:
var message := str(parsed) if parsed != null else "Dynamic Questions request failed."
if operation == "report":
report_failed.emit(_report_question_id, message)
else:
request_failed.emit(message)
return
var data: Dictionary = parsed
match operation:
"status":
status_loaded.emit(data)
"next":
run_token = str(data.get("runToken", ""))
generation_token = str(data.get("generationToken", ""))
current_envelope = data
var question := QuestionBankService.normalize_api_question(data.get("question", {}))
question["_dynamic_objective_id"] = str(data.get("objectiveId", ""))
question["_dynamic_objective_title"] = str(data.get("objectiveTitle", ""))
question_loaded.emit(question)
"attempt":
attempt_completed.emit(data)
"report":
report_submitted.emit(_report_question_id, str(data.get("id", "")))
@@ -0,0 +1 @@
uid://b8u2b8km7k6t
+79
View File
@@ -0,0 +1,79 @@
extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd")
var main
var status_label: Label
var start_button: Button
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
var card := VBoxContainer.new()
card.custom_minimum_size = Vector2(620, 0)
card.add_theme_constant_override("separation", 18)
center.add_child(card)
var title := Label.new()
title.text = "Dynamic Questions"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 34)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
card.add_child(title)
var description := Label.new()
description.text = "Endless, data-driven Step 3 practice. Clinical values and distractors vary while each tested objective stays medically invariant."
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
card.add_child(description)
status_label = Label.new()
status_label.text = "Checking available objectives…"
status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
card.add_child(status_label)
start_button = Button.new()
start_button.text = "Start Dynamic Questions"
start_button.custom_minimum_size = Vector2(0, 52)
start_button.disabled = true
start_button.pressed.connect(_start)
card.add_child(start_button)
DynamicQuestionService.status_loaded.connect(_on_status)
DynamicQuestionService.question_loaded.connect(_on_question)
DynamicQuestionService.request_failed.connect(_on_failed)
func on_show(_payload: Variant = null) -> void:
start_button.disabled = true
status_label.text = "Checking available objectives…"
DynamicQuestionService.load_status()
func _on_status(status: Dictionary) -> void:
var available := bool(status.get("available", false))
var count := int(status.get("publishedObjectives", 0))
var attempts := int(status.get("totalAttempts", 0))
status_label.text = "%d objectives • %d attempts • mastery %d" % [
count, attempts, int(status.get("averageObjectiveElo", 1000))]
start_button.disabled = not available
if not available:
status_label.text = "Dynamic question content has not been published yet."
func _start() -> void:
start_button.disabled = true
start_button.text = "Generating…"
DynamicQuestionService.reset_run()
DynamicQuestionService.load_next()
func _on_question(question: Dictionary) -> void:
start_button.text = "Start Dynamic Questions"
start_button.disabled = false
if BlockSessionService.start_dynamic_question(question):
main.show_screen("player")
func _on_failed(message: String) -> void:
start_button.text = "Try Again"
start_button.disabled = false
status_label.text = message
@@ -0,0 +1 @@
uid://53fdgx3f3am1
+9
View File
@@ -248,6 +248,12 @@ func _build_ui() -> void:
screens_container.add_child(selector)
screens["selection"] = selector
var dynamic_questions := preload("res://scripts/ui/dynamic_questions_screen.gd").new()
dynamic_questions.name = "DynamicQuestionsScreen"
dynamic_questions.main = self
screens_container.add_child(dynamic_questions)
screens["dynamic_questions"] = dynamic_questions
var map := preload("res://scripts/ui/study_map.gd").new()
map.name = "StudyMap"
map.main = self
@@ -410,6 +416,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
_add_nav_button(container, "home", "Dashboard", "chart-no-axes-column-increasing", true)
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true)
_add_nav_button(container, "dynamic_questions", "Dynamic Questions", "shuffle", true)
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true)
@@ -454,6 +461,7 @@ func _on_navigation_pressed(key: String) -> void:
match key:
"home": show_screen("dashboard")
"question_bank": show_screen("selection", "question_bank")
"dynamic_questions": show_screen("dynamic_questions")
"exams": show_screen("simulation_history")
"ccs": show_screen("ccs_hub")
"flashcards": show_screen("flashcards_study")
@@ -472,6 +480,7 @@ func _update_header_title(screen_name: String) -> void:
var titles := {
"dashboard": "Dashboard",
"selection": "Question Bank",
"dynamic_questions": "Dynamic Questions",
"simulation_history": "Exam Simulations",
"ccs_hub": "CCS Cases",
"flashcards_hub": "Flashcards",
+3 -1
View File
@@ -10,7 +10,9 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
elif screen_name in ["map", "setup", "review"]:
key = "study_plan" if screen_name == "map" else "question_bank"
elif screen_name == "player":
key = "question_bank"
key = "dynamic_questions" if BlockSessionService.session_kind == "dynamic" else "question_bank"
elif screen_name == "dynamic_questions":
key = "dynamic_questions"
elif screen_name.begins_with("simulation_"):
key = "exams"
elif screen_name.begins_with("ccs_"):
+54 -9
View File
@@ -164,6 +164,14 @@ func _ready() -> void:
QuestionBankService.question_report_submitted.connect(_on_question_report_submitted)
if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed):
QuestionBankService.question_report_failed.connect(_on_question_report_failed)
if not DynamicQuestionService.question_loaded.is_connected(_on_dynamic_question_loaded):
DynamicQuestionService.question_loaded.connect(_on_dynamic_question_loaded)
if not DynamicQuestionService.request_failed.is_connected(_on_dynamic_question_failed):
DynamicQuestionService.request_failed.connect(_on_dynamic_question_failed)
if not DynamicQuestionService.report_submitted.is_connected(_on_question_report_submitted):
DynamicQuestionService.report_submitted.connect(_on_question_report_submitted)
if not DynamicQuestionService.report_failed.is_connected(_on_question_report_failed):
DynamicQuestionService.report_failed.connect(_on_question_report_failed)
if not QuestionBankService.questions_changed.is_connected(_on_random_question_changed):
QuestionBankService.questions_changed.connect(_on_random_question_changed)
if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed):
@@ -1115,7 +1123,7 @@ func _load_question() -> void:
next_button.text = "Submit"
next_button.disabled = true
skip_button.disabled = false
skip_button.visible = BlockSessionService.session_kind != "review"
skip_button.visible = BlockSessionService.session_kind not in ["review", "dynamic"]
skip_button.text = "Skip"
report_button.visible = BlockSessionService.session_kind != "simulation"
@@ -1123,13 +1131,16 @@ func _load_question() -> void:
if question.is_empty():
if BlockSessionService.session_kind == "review":
main.show_screen("dashboard")
elif BlockSessionService.session_kind == "dynamic":
main.show_screen("dynamic_questions")
else:
main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3")
return
BookmarkService.observe_question(question)
if BlockSessionService.session_kind != "dynamic":
BookmarkService.observe_question(question)
progress_button.text = _question_progress_text()
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review"]
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review"] else "Open question navigator"
progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"]
progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] else "Open question navigator"
var show_timer := _is_session_timer_visible()
timer_label.visible = show_timer
timer_bar.visible = show_timer
@@ -1373,6 +1384,12 @@ func _submit_or_next() -> void:
if selected_choice == "":
return
var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", ""))
if BlockSessionService.session_kind == "dynamic":
var elapsed := int(floor(BlockSessionService.get_current_elapsed_seconds()))
DynamicQuestionService.submit_attempt(selected_choice, elapsed)
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, false)
_show_answer_review()
return
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
var attempted_choice := selected_choice
if BlockSessionService.session_kind == "core":
@@ -1422,7 +1439,12 @@ func _submit_or_next() -> void:
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
_show_answer_review()
else:
if BlockSessionService.session_kind == "core":
if BlockSessionService.session_kind == "dynamic":
awaiting_next_question = true
next_button.text = "Generating…"
next_button.disabled = true
DynamicQuestionService.load_next()
elif BlockSessionService.session_kind == "core":
_request_next_core_question()
elif BlockSessionService.session_kind == "bookmark":
if BookmarkService.has_next_practice():
@@ -1441,6 +1463,26 @@ func _submit_or_next() -> void:
_advance_or_finish()
func _on_dynamic_question_loaded(question: Dictionary) -> void:
if not awaiting_next_question or BlockSessionService.session_kind != "dynamic":
return
if not BlockSessionService.replace_dynamic_question(question):
_on_dynamic_question_failed("The next dynamic question could not be prepared.")
return
awaiting_next_question = false
_load_question()
UISoundService.play_sound("question")
func _on_dynamic_question_failed(message: String) -> void:
if not awaiting_next_question or BlockSessionService.session_kind != "dynamic":
return
awaiting_next_question = false
next_button.text = "Retry"
next_button.disabled = false
next_button.tooltip_text = message
func _request_next_core_question() -> void:
awaiting_next_question = true
next_button.text = "Loading..."
@@ -2053,12 +2095,12 @@ func _refresh_toolbar_state() -> void:
var question_id := str(BlockSessionService.get_current_question().get("id", ""))
report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question"
if notes_button != null:
notes_button.visible = BlockSessionService.session_kind != "simulation"
notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
if flashcard_button != null:
flashcard_button.visible = BlockSessionService.session_kind != "simulation"
flashcard_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
flashcard_button.disabled = current_answer.is_empty()
if bookmark_button != null:
bookmark_button.visible = BlockSessionService.session_kind != "simulation"
bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", ""))
var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id)
var bookmark_pending := BookmarkService.is_pending(bookmark_question_id)
@@ -2797,7 +2839,10 @@ func _submit_question_report() -> void:
return
report_error_label.text = ""
_set_report_submitting(true)
QuestionBankService.submit_question_report(report_question_id, category, details)
if BlockSessionService.session_kind == "dynamic":
DynamicQuestionService.submit_report(report_question_id, category, details)
else:
QuestionBankService.submit_question_report(report_question_id, category, details)
func _on_question_report_submitted(question_id: String, _report_id: String) -> void:
+1 -11
View File
@@ -155,7 +155,7 @@ func _build_mode_view() -> void:
mode_view.add_child(title)
var subtitle := Label.new()
subtitle.text = "Build clinical judgment through focused questions. Additional modes are in development."
subtitle.text = "Build clinical judgment through focused authored questions."
subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
subtitle.add_theme_font_override("font", Brand.font)
subtitle.add_theme_font_size_override("font_size", 17)
@@ -187,16 +187,6 @@ func _build_mode_view() -> void:
retry_button = question_card.retry
retry_button.pressed.connect(_retry_question_bank)
_add_mode_card(
"Dynamic Questions",
"AI-generated practice",
"Coming Soon",
"Coming Soon",
"Endless practice with AI-generated questions\ntailored to your performance.",
"activity",
false,
Brand.PURPLE)
var ccs_card := _add_mode_card(
"CCS Cases",
"Interactive case simulations",