extends Node const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_QUESTIONS_API_URL := "https://us3q.ranaze.com/api/questions/search" const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://us3q.ranaze.com/api/questions/attempts/first" const DEFAULT_QUESTION_API_BASE_URL := "https://us3q.ranaze.com/api/questions" const DEFAULT_RATINGS_API_URL := "https://us3q.ranaze.com/api/ratings/me" const DEFAULT_QUESTION_REPORTS_API_URL := "https://us3q.ranaze.com/api/question-reports" const RETRY_DELAYS := [2, 5, 10, 20, 30] const REQUEST_TIMEOUT_SECONDS := 30.0 const IMAGE_QUESTION_SEARCH_LIMIT := 200 const ATTEMPT_QUEUE_PATH := "user://pending_question_attempts.json" signal questions_changed signal questions_load_failed(message: String) signal choice_percentages_updated(question_id: String, percentages: Dictionary) signal ratings_changed signal question_rating_updated(question_id: String, question_elo: int, rating: Dictionary) signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary) signal question_report_submitted(question_id: String, report_id: String) signal question_report_failed(question_id: String, message: String) var questions: Array = [] var request: HTTPRequest var attempt_request: HTTPRequest var rating_request: HTTPRequest var report_request: HTTPRequest var retry_timer: Timer var attempt_retry_timer: Timer var questions_api_url := DEFAULT_QUESTIONS_API_URL var question_attempt_api_url := DEFAULT_QUESTION_ATTEMPT_API_URL var question_api_base_url := DEFAULT_QUESTION_API_BASE_URL var ratings_api_url := DEFAULT_RATINGS_API_URL var question_reports_api_url := DEFAULT_QUESTION_REPORTS_API_URL var is_loading := false var last_error := "" var load_state := "idle" var retry_attempt := 0 var next_retry_seconds := 0 var overall_elo := 1000 var rank_tier := "Bronze" var rank_division := 3 var rated_question_count := 0 var category_elos := {} var last_rating_change := {} var pending_report_question_id := "" var attempt_queue: Array = [] var active_attempt_index := -1 var attempt_request_active := false var questions_request_token := "" var attempt_request_token := "" var rating_request_token := "" var report_request_token := "" var pending_excluded_question_ids: Array = [] var pending_require_media := false var pending_step3_system := "" var pending_difficulty_min := 0 var pending_difficulty_max := 0 var pending_limit := 1 var targeted_fallback_used := false var image_question_search_attempts := 0 func _ready() -> void: questions_api_url = EndpointConfig.resolve( str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)), "/api/questions/search") question_attempt_api_url = EndpointConfig.resolve( str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL)), "/api/questions/attempts/first") question_api_base_url = EndpointConfig.resolve( str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL)), "/api/questions") ratings_api_url = EndpointConfig.resolve( str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL)), "/api/ratings/me") question_reports_api_url = EndpointConfig.resolve( str(ProjectSettings.get_setting("step3/question_reports_api_url", DEFAULT_QUESTION_REPORTS_API_URL)), "/api/question-reports") request = HTTPRequest.new() request.timeout = REQUEST_TIMEOUT_SECONDS request.request_completed.connect(_on_api_questions_loaded) add_child(request) attempt_request = HTTPRequest.new() attempt_request.timeout = REQUEST_TIMEOUT_SECONDS attempt_request.request_completed.connect(_on_first_attempt_recorded) add_child(attempt_request) rating_request = HTTPRequest.new() rating_request.timeout = REQUEST_TIMEOUT_SECONDS rating_request.request_completed.connect(_on_ratings_loaded) add_child(rating_request) report_request = HTTPRequest.new() report_request.timeout = REQUEST_TIMEOUT_SECONDS report_request.request_completed.connect(_on_question_report_submitted) add_child(report_request) retry_timer = Timer.new() retry_timer.one_shot = true retry_timer.timeout.connect(_on_retry_timer_timeout) add_child(retry_timer) attempt_retry_timer = Timer.new() attempt_retry_timer.one_shot = true attempt_retry_timer.timeout.connect(_process_attempt_queue) add_child(attempt_retry_timer) _load_attempt_queue() if not AuthState.auth_changed.is_connected(_on_auth_changed_for_attempt_queue): AuthState.auth_changed.connect(_on_auth_changed_for_attempt_queue) _process_attempt_queue() func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = [], require_media: bool = false, step3_system: String = "", difficulty_min: int = 0, difficulty_max: int = 0, requested_limit: int = 1) -> void: if not AuthState.can_send_authenticated_request(): _set_load_error("Login required before loading questions.", false) return if reset_retry: pending_excluded_question_ids = excluded_question_ids.duplicate() pending_require_media = require_media pending_step3_system = step3_system pending_difficulty_min = difficulty_min pending_difficulty_max = difficulty_max pending_limit = clampi(requested_limit, 1, 40) targeted_fallback_used = false image_question_search_attempts = 0 retry_attempt = 0 next_retry_seconds = 0 retry_timer.stop() if is_loading or request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: request.cancel_request() is_loading = false call_deferred("load_questions_from_api", false, pending_excluded_question_ids, pending_require_media) return elif is_loading or request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: # A late timer or repeated click must not turn an active request into an # immediate ERR_BUSY failure. return var should_load_ratings := load_state == "idle" is_loading = true load_state = "retrying" if retry_attempt > 0 else "loading" last_error = "" questions.clear() questions_changed.emit() if should_load_ratings: load_ratings_from_api() questions_request_token = AuthState.access_token var headers := PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % questions_request_token ]) var payload := { "limit": pending_limit, "supportsChoicePools": true, "requireMedia": pending_require_media, "excludeExternalIds": pending_excluded_question_ids } if not pending_step3_system.is_empty(): payload["step3System"] = pending_step3_system if pending_difficulty_min > 0: payload["difficultyMin"] = pending_difficulty_min if pending_difficulty_max > 0: payload["difficultyMax"] = pending_difficulty_max var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if err != OK: _set_load_error("Unable to connect to the question service.") func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: is_loading = false if response_code == 401: AuthState.handle_unauthorized(questions_request_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: var message := "The connection to the question service was interrupted." if response_code > 0: message = "Questions API request failed with status %d." % response_code _set_load_error(message, _is_transient_question_failure(result, response_code)) return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_ARRAY: _set_load_error("Questions API response was not an array.", false) return var api_questions: Array = [] for item in parsed: var normalized := _normalize_api_question(item) if _is_valid_question(normalized): api_questions.append(normalized) if api_questions.is_empty(): if not pending_step3_system.is_empty() and (pending_difficulty_min > 0 or pending_difficulty_max > 0) and not targeted_fallback_used: targeted_fallback_used = true pending_difficulty_min = 0 pending_difficulty_max = 0 call_deferred("load_questions_from_api", false, pending_excluded_question_ids, pending_require_media) return _set_load_error("Questions API returned no usable questions.", false) return if pending_require_media and not _contains_question_with_media(api_questions): for question in api_questions: var question_id := str(question.get("id", "")) if question_id != "" and not pending_excluded_question_ids.has(question_id): pending_excluded_question_ids.append(question_id) image_question_search_attempts += api_questions.size() if image_question_search_attempts >= IMAGE_QUESTION_SEARCH_LIMIT: _set_load_error("No question with an image was found.", false) return call_deferred("load_questions_from_api", false, pending_excluded_question_ids, true) return questions = api_questions last_error = "" load_state = "ready" retry_attempt = 0 next_retry_seconds = 0 retry_timer.stop() questions_changed.emit() func load_targeted_questions(step3_system: String, question_count: int, difficulty_min: int = 0, difficulty_max: int = 0) -> void: load_questions_from_api(true, [], false, step3_system, difficulty_min, difficulty_max, question_count) func retry_now() -> void: retry_attempt = 0 next_retry_seconds = 0 retry_timer.stop() if is_loading or request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: request.cancel_request() is_loading = false call_deferred("load_questions_from_api", false, pending_excluded_question_ids, pending_require_media) func load_next_random_question(excluded_question_ids: Array) -> void: load_questions_from_api(true, excluded_question_ids) func load_image_question_for_testing() -> void: load_questions_from_api(true, [], true) func _contains_question_with_media(question_list: Array) -> bool: for question in question_list: var media: Array = question.get("media", []) if not media.is_empty(): return true return false func clear_questions() -> void: if request != null: request.cancel_request() if attempt_request != null: attempt_request.cancel_request() if rating_request != null: rating_request.cancel_request() if report_request != null: report_request.cancel_request() if retry_timer != null: retry_timer.stop() if attempt_retry_timer != null: attempt_retry_timer.stop() attempt_request_active = false active_attempt_index = -1 questions.clear() is_loading = false last_error = "" load_state = "idle" retry_attempt = 0 next_retry_seconds = 0 overall_elo = 1000 rank_tier = "Bronze" rank_division = 3 rated_question_count = 0 category_elos = {} last_rating_change = {} pending_report_question_id = "" pending_excluded_question_ids.clear() pending_require_media = false pending_step3_system = "" pending_difficulty_min = 0 pending_difficulty_max = 0 pending_limit = 1 targeted_fallback_used = false image_question_search_attempts = 0 questions_changed.emit() ratings_changed.emit() func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void: if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "": return _enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds, met_time_limit) func skip_question(question_id: String) -> void: if not AuthState.can_send_authenticated_request() or question_id == "": return _enqueue_attempt("skip", question_id, "") func submit_question_report(question_id: String, category: String, details: String) -> bool: if not AuthState.can_send_authenticated_request() or question_id == "": question_report_failed.emit(question_id, "Login is required to submit a report.") return false if report_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: question_report_failed.emit(question_id, "Another report is still being submitted.") return false report_request_token = AuthState.access_token var headers := PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % report_request_token ]) var payload := { "questionExternalId": question_id, "category": category, "details": details } pending_report_question_id = question_id var err := report_request.request( question_reports_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload) ) if err != OK: pending_report_question_id = "" question_report_failed.emit(question_id, "The report could not be submitted. Please try again.") return false return true func load_ratings_from_api() -> void: if not AuthState.can_send_authenticated_request(): return rating_request_token = AuthState.access_token var headers := PackedStringArray([ "Authorization: Bearer %s" % rating_request_token ]) var err := rating_request.request(ratings_api_url, headers, HTTPClient.METHOD_GET) if err != OK: push_warning("Failed to request rating profile.") func get_all_questions() -> Array: return questions.duplicate(true) func get_systems() -> Array[String]: var system_names: Array[String] = [] for question in questions: var system_name := str(question.get("system", "General")) if not system_names.has(system_name): system_names.append(system_name) system_names.sort() return system_names func get_questions_for_system(system_name: String) -> Array: if system_name == "All Systems": return get_all_questions() var filtered: Array = [] for question in questions: if str(question.get("step3_system", question.get("system", ""))) == system_name or str(question.get("system", "")) == system_name: filtered.append(question.duplicate(true)) return filtered func build_block(system_name: String, block_size: int) -> Array: var pool := get_questions_for_system(system_name) pool.shuffle() return pool.slice(0, min(block_size, pool.size())) func has_questions() -> bool: return not questions.is_empty() func _set_load_error(message: String, schedule_retry: bool = true) -> void: is_loading = false last_error = message push_warning(message) if schedule_retry and not AuthState.access_token.is_empty(): _schedule_retry() else: load_state = "failed" next_retry_seconds = 0 questions_load_failed.emit(message) questions_changed.emit() func _is_transient_question_failure(result: int, response_code: int) -> bool: if result != HTTPRequest.RESULT_SUCCESS: return true return response_code == 0 or response_code == 408 or response_code == 425 \ or response_code == 429 or response_code >= 500 func _schedule_retry() -> void: var delay_index := retry_attempt if delay_index >= RETRY_DELAYS.size(): delay_index = RETRY_DELAYS.size() - 1 next_retry_seconds = int(RETRY_DELAYS[delay_index]) retry_attempt += 1 load_state = "retrying" retry_timer.start(float(next_retry_seconds)) func _on_retry_timer_timeout() -> void: next_retry_seconds = 0 if is_loading or request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: retry_timer.start(1.0) return load_questions_from_api(false) func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void: var account := AuthState.username.strip_edges() if account == "": return for queued in attempt_queue: if str(queued.get("account", "")) == account and str(queued.get("question_id", "")) == question_id: return attempt_queue.append({ "account": account, "kind": kind, "question_id": question_id, "choice_id": choice_id, "presented_choice_ids": presented_choice_ids.duplicate(), "duration_seconds": maxi(0, duration_seconds), "met_time_limit": met_time_limit, "study_session_id": StudySessionService.active_session_id, "retry_count": 0 }) _save_attempt_queue() _process_attempt_queue() func _process_attempt_queue() -> void: if attempt_request_active or attempt_queue.is_empty() or not AuthState.can_send_authenticated_request(): return if attempt_retry_timer != null and not attempt_retry_timer.is_stopped(): return active_attempt_index = -1 for index in range(attempt_queue.size()): if str(attempt_queue[index].get("account", "")) == AuthState.username: active_attempt_index = index break if active_attempt_index < 0: return var queued: Dictionary = attempt_queue[active_attempt_index] attempt_request_token = AuthState.access_token var headers := PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % attempt_request_token ]) var question_id := str(queued.get("question_id", "")) var url := question_attempt_api_url var body := JSON.stringify({ "questionExternalId": question_id, "choiceId": str(queued.get("choice_id", "")), "presentedChoiceIds": queued.get("presented_choice_ids", []), "durationSeconds": int(queued.get("duration_seconds", 0)), "metTimeLimit": bool(queued.get("met_time_limit", true)) }) var study_session_id := str(queued.get("study_session_id", "")) if not study_session_id.is_empty(): var decoded: Dictionary = JSON.parse_string(body) decoded["studySessionId"] = study_session_id body = JSON.stringify(decoded) if str(queued.get("kind", "answer")) == "skip": url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()] body = "" attempt_request_active = true var err := attempt_request.request(url, headers, HTTPClient.METHOD_POST, body) if err != OK: attempt_request_active = false _schedule_attempt_retry() func _schedule_attempt_retry() -> void: if active_attempt_index < 0 or active_attempt_index >= attempt_queue.size(): return var queued: Dictionary = attempt_queue[active_attempt_index] var retry_count := int(queued.get("retry_count", 0)) + 1 queued["retry_count"] = retry_count attempt_queue[active_attempt_index] = queued _save_attempt_queue() var delay_index: int = min(retry_count - 1, RETRY_DELAYS.size() - 1) attempt_retry_timer.start(float(RETRY_DELAYS[delay_index])) func _remove_active_attempt() -> void: if active_attempt_index >= 0 and active_attempt_index < attempt_queue.size(): attempt_queue.remove_at(active_attempt_index) _save_attempt_queue() active_attempt_index = -1 func _on_first_attempt_recorded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: attempt_request_active = false if active_attempt_index < 0 or active_attempt_index >= attempt_queue.size(): return var queued: Dictionary = attempt_queue[active_attempt_index] var succeeded := result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300 if not succeeded: if response_code == 401: AuthState.handle_unauthorized(attempt_request_token) active_attempt_index = -1 elif response_code in [400, 403, 404]: push_warning("Discarding invalid question attempt with status %d." % response_code) _remove_active_attempt() _process_attempt_queue() else: _schedule_attempt_retry() return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: _schedule_attempt_retry() return _remove_active_attempt() if str(queued.get("kind", "answer")) == "skip": _apply_skip_response(parsed) else: _apply_first_attempt_response(parsed) _process_attempt_queue() func _apply_first_attempt_response(parsed: Dictionary) -> void: var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {})) if question_id == "": return _update_question_percentages(question_id, percentages) var question_elo := int(parsed.get("questionElo", parsed.get("question_elo", 1000))) var rating: Dictionary = parsed.get("rating", {}) _update_question_elo(question_id, question_elo) _apply_rating_change(rating) CreditsService.apply_award_response(parsed) DailyMissionService.apply_response(parsed) # Let Tutor users inspect the final answer before the summary replaces the player. StudySessionService.apply_response(parsed, true) choice_percentages_updated.emit(question_id, percentages) question_rating_updated.emit(question_id, question_elo, rating) func _apply_skip_response(parsed: Dictionary) -> void: var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) var correct_choice_id := str(parsed.get("correctChoiceId", parsed.get("correct_choice_id", ""))) var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {})) var question_elo := int(parsed.get("questionElo", parsed.get("question_elo", 1000))) var rating: Dictionary = parsed.get("rating", {}) if question_id == "": return _update_question_percentages(question_id, percentages) _update_question_elo(question_id, question_elo) _apply_rating_change(rating) choice_percentages_updated.emit(question_id, percentages) question_skipped.emit(question_id, correct_choice_id, question_elo, rating) func _on_auth_changed_for_attempt_queue() -> void: if AuthState.access_token.is_empty(): if attempt_retry_timer != null: attempt_retry_timer.stop() return _process_attempt_queue() func _load_attempt_queue() -> void: attempt_queue = [] if not FileAccess.file_exists(ATTEMPT_QUEUE_PATH): return var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(ATTEMPT_QUEUE_PATH)) if typeof(parsed) != TYPE_ARRAY: return for item in parsed: if typeof(item) != TYPE_DICTIONARY: continue var account := str(item.get("account", "")).strip_edges() var question_id := str(item.get("question_id", "")).strip_edges() var kind := str(item.get("kind", "answer")) if account == "" or question_id == "" or kind not in ["answer", "skip"]: continue attempt_queue.append({ "account": account, "kind": kind, "question_id": question_id, "choice_id": str(item.get("choice_id", "")), "presented_choice_ids": item.get("presented_choice_ids", []).duplicate(), "retry_count": max(0, int(item.get("retry_count", 0))) }) func _save_attempt_queue() -> void: var file := FileAccess.open(ATTEMPT_QUEUE_PATH, FileAccess.WRITE) if file == null: push_warning("Could not persist pending question attempts.") return file.store_string(JSON.stringify(attempt_queue)) func _on_question_report_submitted(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) var question_id := "" if typeof(parsed) == TYPE_DICTIONARY: question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) if question_id == "": question_id = pending_report_question_id pending_report_question_id = "" if response_code == 401: AuthState.handle_unauthorized(report_request_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: var message := "The report could not be submitted. Please try again." if typeof(parsed) == TYPE_DICTIONARY: message = str(parsed.get("detail", parsed.get("title", message))) elif typeof(parsed) == TYPE_STRING and not str(parsed).strip_edges().is_empty(): message = str(parsed).strip_edges() question_report_failed.emit(question_id, message) return if typeof(parsed) != TYPE_DICTIONARY: question_report_failed.emit(question_id, "The report API returned an invalid response.") return var report_id := str(parsed.get("id", "")) if question_id == "" or report_id == "": question_report_failed.emit(question_id, "The report API returned an incomplete response.") return question_report_submitted.emit(question_id, report_id) func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if response_code == 401: AuthState.handle_unauthorized(rating_request_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: push_warning("Ratings API request failed with status %d." % response_code) return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_DICTIONARY: push_warning("Ratings API response was not an object.") return overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000))) rank_tier = str(parsed.get("tier", rank_tier)) rank_division = int(parsed.get("tierDivision", parsed.get("tier_division", rank_division))) rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0))) category_elos = parsed.get("categoryElos", parsed.get("category_elos", {})) ratings_changed.emit() func _update_question_percentages(question_id: String, percentages: Dictionary) -> void: for index in range(questions.size()): var question: Dictionary = questions[index] if str(question.get("id", "")) == question_id: question["choice_selection_percentages"] = percentages questions[index] = question return func _update_question_elo(question_id: String, question_elo: int) -> void: for index in range(questions.size()): var question: Dictionary = questions[index] if str(question.get("id", "")) == question_id: question["question_elo"] = question_elo questions[index] = question return func _apply_rating_change(rating: Dictionary) -> void: if rating.is_empty(): return last_rating_change = rating overall_elo = int(rating.get("userOverallElo", rating.get("user_overall_elo", overall_elo))) var category := str(rating.get("category", "")) if category != "": category_elos[category] = int(rating.get("userCategoryElo", rating.get("user_category_elo", category_elos.get(category, 1000)))) rated_question_count += 1 if bool(rating.get("rated", false)) else 0 ratings_changed.emit() func _is_valid_question(item: Variant) -> bool: if typeof(item) != TYPE_DICTIONARY: return false var required := ["id", "stem", "lead_in", "choices", "correct_choice_id", "explanation", "system", "discipline"] for key in required: if not item.has(key): return false return typeof(item["choices"]) == TYPE_ARRAY and item["choices"].size() >= 2 func _normalize_api_question(item: Variant) -> Dictionary: if typeof(item) != TYPE_DICTIONARY: return {} var normalized := { "id": str(item.get("externalId", item.get("id", ""))), "api_id": str(item.get("id", "")), "stem": str(item.get("stem", "")), "lead_in": str(item.get("leadIn", item.get("lead_in", ""))), "choices": item.get("choices", []), "correct_choice_id": str(item.get("correctChoiceId", item.get("correct_choice_id", ""))), "explanation": str(item.get("explanation", "")), "key_takeaway": str(item.get("keyTakeaway", item.get("key_takeaway", ""))), "usmle_pearl": str(item.get("usmlePearl", item.get("usmle_pearl", ""))), "review_followups": _normalize_review_followups(item.get("reviewFollowups", item.get("review_followups", []))), "choice_explanations": item.get("choiceExplanations", item.get("choice_explanations", {})), "wrong_answer_followups": item.get("wrongAnswerFollowups", item.get("wrong_answer_followups", {})), "choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})), "discipline": str(item.get("discipline", "")), "system": str(item.get("system", "")), "step3_system": str(item.get("step3System", item.get("step3_system", ""))), "performance_category": str(item.get("performanceCategory", item.get("performance_category", ""))), "difficulty": int(item.get("difficulty", 1)), "question_elo": int(item.get("questionElo", item.get("question_elo", 1000))), "is_bookmarked": bool(item.get("isBookmarked", item.get("is_bookmarked", false))), "labs": item.get("labs", []), "media": item.get("media", []), "tags": item.get("tags", []), "references": _normalize_references(item.get("references", [])) } return normalized func normalize_api_question(item: Variant) -> Dictionary: return _normalize_api_question(item) func _normalize_review_followups(raw_followups: Variant) -> Array: var normalized: Array = [] if typeof(raw_followups) != TYPE_ARRAY: return normalized for raw_followup in raw_followups: if typeof(raw_followup) != TYPE_DICTIONARY: continue normalized.append({ "prompt": str(raw_followup.get("prompt", "")).strip_edges(), "choices": raw_followup.get("choices", []), "correct_choice_id": str(raw_followup.get("correctChoiceId", raw_followup.get("correct_choice_id", ""))), "teaching_point": str(raw_followup.get("teachingPoint", raw_followup.get("teaching_point", ""))).strip_edges() }) return normalized func _normalize_references(raw_references: Variant) -> Array: var normalized: Array = [] if typeof(raw_references) != TYPE_ARRAY: return normalized for raw_reference in raw_references: if typeof(raw_reference) != TYPE_DICTIONARY: continue normalized.append({ "title": str(raw_reference.get("title", raw_reference.get("Title", ""))).strip_edges(), "publisher": str(raw_reference.get("publisher", raw_reference.get("Publisher", ""))).strip_edges(), "url": str(raw_reference.get("url", raw_reference.get("Url", ""))).strip_edges(), "supports": str(raw_reference.get("supports", raw_reference.get("Supports", ""))).strip_edges(), "accessed_on": str(raw_reference.get("accessedOn", raw_reference.get("accessed_on", raw_reference.get("AccessedOn", "")))).strip_edges(), "reviewer": str(raw_reference.get("reviewer", raw_reference.get("Reviewer", ""))).strip_edges() }) return normalized