Files
step3/scripts/autoload/question_bank_service.gd
T
2026-08-01 04:02:24 -05:00

779 lines
29 KiB
GDScript

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