Add data-driven dynamic question mode
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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_"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user