From 474c704aa9d3980d8f8f845a4cda614e3e0f8cfe Mon Sep 17 00:00:00 2001 From: hz Date: Thu, 9 Jul 2026 21:35:54 -0500 Subject: [PATCH] Require API question loading --- data/questions/sample_questions.json | 166 ---------------------- scripts/autoload/question_bank_service.gd | 54 +++---- scripts/ui/block_setup.gd | 24 +++- scripts/ui/study_map.gd | 24 ++++ 4 files changed, 69 insertions(+), 199 deletions(-) delete mode 100644 data/questions/sample_questions.json diff --git a/data/questions/sample_questions.json b/data/questions/sample_questions.json deleted file mode 100644 index 1635f24..0000000 --- a/data/questions/sample_questions.json +++ /dev/null @@ -1,166 +0,0 @@ -[ - { - "id": "sample-001", - "stem": "A 67-year-old man comes to the emergency department because of 2 hours of substernal chest pressure and diaphoresis. He has hypertension, type 2 diabetes mellitus, and chronic kidney disease. Blood pressure is 88/54 mm Hg and pulse is 112/min. ECG shows ST-segment elevation in leads II, III, and aVF. He receives aspirin and is taken for urgent coronary angiography.", - "lead_in": "Which of the following findings would most strongly suggest right ventricular infarction?", - "choices": [ - { "id": "A", "text": "Diffuse wheezing and pulsus paradoxus" }, - { "id": "B", "text": "Hypotension with clear lung fields and elevated jugular venous pressure" }, - { "id": "C", "text": "New holosystolic murmur radiating to the axilla" }, - { "id": "D", "text": "Pulmonary edema with severe hypertension" }, - { "id": "E", "text": "Wide pulse pressure and bounding peripheral pulses" } - ], - "correct_choice_id": "B", - "explanation": "Inferior myocardial infarction can involve the right ventricle. Right ventricular infarction classically causes hypotension, elevated jugular venous pressure, and clear lungs due to impaired right-sided filling without primary left-sided pulmonary congestion.", - "choice_explanations": { - "A": "This pattern suggests obstructive airway disease or tamponade physiology, not the classic right ventricular infarction triad.", - "B": "Correct. Hypotension, jugular venous distention, and clear lungs are classic for right ventricular infarction.", - "C": "A new holosystolic murmur radiating to the axilla suggests acute mitral regurgitation.", - "D": "Pulmonary edema with hypertension suggests left-sided heart failure or hypertensive emergency.", - "E": "A wide pulse pressure suggests aortic regurgitation or high-output states." - }, - "discipline": "Medicine", - "system": "Cardiovascular", - "difficulty": 2, - "labs": [], - "media": [], - "tags": ["acute coronary syndrome", "inferior MI", "shock"] - }, - { - "id": "sample-002", - "stem": "A 29-year-old woman at 34 weeks' gestation is brought to labor and delivery because of a generalized tonic-clonic seizure. She has had headache and visual spots for 1 day. Blood pressure is 172/112 mm Hg. Urine dipstick shows 3+ protein. Fetal heart tracing is category II.", - "lead_in": "Which of the following is the most appropriate immediate treatment?", - "choices": [ - { "id": "A", "text": "Diazepam infusion" }, - { "id": "B", "text": "Immediate cesarean delivery before medication" }, - { "id": "C", "text": "Intravenous magnesium sulfate" }, - { "id": "D", "text": "Oral labetalol and outpatient follow-up" }, - { "id": "E", "text": "Warfarin anticoagulation" } - ], - "correct_choice_id": "C", - "explanation": "Eclampsia is preeclampsia with seizures. Magnesium sulfate is first-line seizure treatment and prevention. Severe hypertension should also be controlled, and delivery is indicated after maternal stabilization.", - "choice_explanations": { - "A": "Benzodiazepines are not first-line for eclamptic seizures unless seizures persist despite magnesium.", - "B": "Delivery is definitive, but maternal stabilization comes first.", - "C": "Correct. Magnesium sulfate is first-line therapy for eclampsia.", - "D": "This patient has eclampsia and severe-range blood pressure requiring inpatient emergency treatment.", - "E": "Warfarin has no role and is generally avoided in pregnancy." - }, - "discipline": "Obstetrics/Gynecology", - "system": "Pregnancy", - "difficulty": 1, - "labs": [], - "media": [], - "tags": ["eclampsia", "hypertension", "pregnancy"] - }, - { - "id": "sample-003", - "stem": "A 52-year-old man is seen for follow-up after a hemicolectomy for stage III colon cancer. He is starting adjuvant chemotherapy. He asks what symptom should make him call the office urgently while receiving fluorouracil-based therapy.", - "lead_in": "Which of the following adverse effects is most important to report immediately?", - "choices": [ - { "id": "A", "text": "Mild hair thinning" }, - { "id": "B", "text": "Metallic taste during infusion" }, - { "id": "C", "text": "New chest pain or shortness of breath" }, - { "id": "D", "text": "Transient darkening of the nails" }, - { "id": "E", "text": "Warmth at the infusion site lasting 5 minutes" } - ], - "correct_choice_id": "C", - "explanation": "Fluoropyrimidines can cause coronary vasospasm and cardiotoxicity. New chest pain or shortness of breath during treatment warrants urgent evaluation.", - "choice_explanations": { - "A": "Mild alopecia can occur but is not typically urgent.", - "B": "Taste changes can occur with chemotherapy and are usually not emergent.", - "C": "Correct. Chest pain or dyspnea can indicate fluoropyrimidine cardiotoxicity.", - "D": "Nail changes may occur with chemotherapy but are not the highest-risk symptom listed.", - "E": "Brief warmth can be benign, though persistent pain or swelling should be reported." - }, - "discipline": "Medicine", - "system": "Oncology", - "difficulty": 3, - "labs": [], - "media": [], - "tags": ["colon cancer", "chemotherapy", "toxicity"] - }, - { - "id": "sample-004", - "stem": "A 44-year-old woman with opioid use disorder is admitted with fever and pleuritic chest pain. Temperature is 39.1 C, pulse is 118/min, and blood pressure is 104/66 mm Hg. CT chest shows multiple peripheral cavitary nodules. Blood cultures grow methicillin-sensitive Staphylococcus aureus.", - "lead_in": "Which of the following is the most likely cardiac finding?", - "choices": [ - { "id": "A", "text": "Aortic valve vegetation with splenic infarcts" }, - { "id": "B", "text": "Mitral stenosis with opening snap" }, - { "id": "C", "text": "Tricuspid valve vegetation with septic pulmonary emboli" }, - { "id": "D", "text": "Ventricular septal rupture after myocardial infarction" }, - { "id": "E", "text": "Viral myocarditis with global hypokinesis" } - ], - "correct_choice_id": "C", - "explanation": "Injection drug use is associated with right-sided infective endocarditis, especially tricuspid valve infection. Septic emboli travel to the lungs and can cause peripheral cavitary nodules.", - "choice_explanations": { - "A": "Left-sided endocarditis more often causes systemic emboli such as splenic or renal infarcts.", - "B": "Mitral stenosis does not explain bacteremia and septic pulmonary emboli.", - "C": "Correct. Tricuspid endocarditis causes septic pulmonary emboli.", - "D": "Ventricular septal rupture causes acute heart failure and a harsh murmur after MI.", - "E": "Myocarditis does not typically cause cavitary septic pulmonary emboli." - }, - "discipline": "Medicine", - "system": "Infectious Disease", - "difficulty": 2, - "labs": [], - "media": [], - "tags": ["endocarditis", "injection drug use", "staphylococcus aureus"] - }, - { - "id": "sample-005", - "stem": "A 7-year-old boy is brought to the clinic because of repetitive blinking and throat clearing for 8 months. His teacher reports that the behaviors increase during tests and decrease when he is focused on drawing. He is otherwise healthy and doing well in school.", - "lead_in": "Which of the following is the most appropriate initial management?", - "choices": [ - { "id": "A", "text": "Begin haloperidol immediately" }, - { "id": "B", "text": "Cognitive behavioral intervention for tics and reassurance" }, - { "id": "C", "text": "Electroencephalography" }, - { "id": "D", "text": "Emergency psychiatric hospitalization" }, - { "id": "E", "text": "High-dose corticosteroids" } - ], - "correct_choice_id": "B", - "explanation": "Persistent motor and vocal tics can be managed initially with education, reassurance, and behavioral therapy when symptoms are not severely impairing. Medication is reserved for more functionally impairing cases.", - "choice_explanations": { - "A": "Antipsychotics can reduce severe tics but are not first-line for mild symptoms.", - "B": "Correct. Behavioral therapy and reassurance are appropriate initial management.", - "C": "The history is typical for tics, not seizures.", - "D": "There is no indication of acute danger requiring hospitalization.", - "E": "Steroids are not indicated." - }, - "discipline": "Pediatrics", - "system": "Psychiatry/Behavioral Health", - "difficulty": 1, - "labs": [], - "media": [], - "tags": ["tic disorder", "behavioral therapy", "pediatrics"] - }, - { - "id": "sample-006", - "stem": "A 61-year-old man with cirrhosis is admitted for confusion. He has asterixis and a distended abdomen. Diagnostic paracentesis shows 620 neutrophils/mm3. Culture results are pending. He has no abdominal rigidity.", - "lead_in": "Which of the following is the most appropriate next step in management?", - "choices": [ - { "id": "A", "text": "Cefotaxime and albumin" }, - { "id": "B", "text": "Colonoscopy" }, - { "id": "C", "text": "Emergent laparotomy" }, - { "id": "D", "text": "High-dose prednisone" }, - { "id": "E", "text": "Therapeutic anticoagulation" } - ], - "correct_choice_id": "A", - "explanation": "Spontaneous bacterial peritonitis is diagnosed when ascitic neutrophils are at least 250/mm3. Treatment includes a third-generation cephalosporin and albumin in selected high-risk patients to reduce renal failure and mortality.", - "choice_explanations": { - "A": "Correct. This patient has spontaneous bacterial peritonitis.", - "B": "Colonoscopy is not the acute treatment.", - "C": "Surgery is considered for secondary peritonitis, especially with peritoneal signs or polymicrobial infection.", - "D": "Steroids are not indicated for SBP.", - "E": "Anticoagulation is not the immediate treatment for infected ascites." - }, - "discipline": "Medicine", - "system": "Gastroenterology", - "difficulty": 2, - "labs": [ - { "name": "Ascitic fluid neutrophils", "value": "620", "unit": "/mm3", "reference": "<250" } - ], - "media": [], - "tags": ["cirrhosis", "ascites", "SBP"] - } -] diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index a7852f6..1b80891 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -1,47 +1,33 @@ extends Node -const QUESTION_FILE := "res://data/questions/sample_questions.json" const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search" signal questions_changed +signal questions_load_failed(message: String) var questions: Array = [] var request: HTTPRequest var questions_api_url := DEFAULT_QUESTIONS_API_URL +var is_loading := false +var last_error := "" func _ready() -> void: questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)) request = HTTPRequest.new() request.request_completed.connect(_on_api_questions_loaded) add_child(request) - load_questions() - - -func load_questions() -> void: - questions.clear() - var file := FileAccess.open(QUESTION_FILE, FileAccess.READ) - if file == null: - push_error("Unable to open question file: %s" % QUESTION_FILE) - return - - var parsed = JSON.parse_string(file.get_as_text()) - if typeof(parsed) != TYPE_ARRAY: - push_error("Question file must contain an array.") - return - - for item in parsed: - if _is_valid_question(item): - questions.append(item) - else: - push_warning("Skipping invalid question: %s" % str(item.get("id", "unknown"))) - - questions_changed.emit() func load_questions_from_api() -> void: if AuthState.access_token.is_empty(): + _set_load_error("Login required before loading questions.") return + is_loading = true + last_error = "" + questions.clear() + questions_changed.emit() + var headers := PackedStringArray([ "Content-Type: application/json", "Authorization: Bearer %s" % AuthState.access_token @@ -51,17 +37,18 @@ func load_questions_from_api() -> void: } var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if err != OK: - push_warning("Failed to request questions API. Using local question bank.") + _set_load_error("Failed to request questions API.") func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + is_loading = false if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: - push_warning("Questions API request failed. Using local question bank.") + _set_load_error("Questions API request failed with status %d." % response_code) return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) if typeof(parsed) != TYPE_ARRAY: - push_warning("Questions API response was not an array. Using local question bank.") + _set_load_error("Questions API response was not an array.") return var api_questions: Array = [] @@ -71,10 +58,11 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS api_questions.append(normalized) if api_questions.is_empty(): - push_warning("Questions API returned no usable questions. Using local question bank.") + _set_load_error("Questions API returned no usable questions.") return questions = api_questions + last_error = "" questions_changed.emit() @@ -109,6 +97,18 @@ func build_block(system_name: String, block_size: int) -> Array: return pool.slice(0, min(block_size, pool.size())) +func has_questions() -> bool: + return not questions.is_empty() + + +func _set_load_error(message: String) -> void: + is_loading = false + last_error = message + push_warning(message) + questions_load_failed.emit(message) + questions_changed.emit() + + func _is_valid_question(item: Variant) -> bool: if typeof(item) != TYPE_DICTIONARY: return false diff --git a/scripts/ui/block_setup.gd b/scripts/ui/block_setup.gd index 41e8098..3cfd3c8 100644 --- a/scripts/ui/block_setup.gd +++ b/scripts/ui/block_setup.gd @@ -7,6 +7,8 @@ var selected_system := "All Systems" var system_label: Label var size_spin: SpinBox var mode_option: OptionButton +var status_label: Label +var start_button: Button func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) @@ -49,12 +51,17 @@ func _ready() -> void: Style.set_label(note, 16, Style.MUTED) add_child(note) - var start := Button.new() - start.text = "Launch Block" - start.custom_minimum_size = Vector2(220, 54) - Style.style_primary_button(start) - start.pressed.connect(_start_block) - add_child(start) + status_label = Label.new() + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(status_label, 16, Style.RED) + add_child(status_label) + + start_button = Button.new() + start_button.text = "Launch Block" + start_button.custom_minimum_size = Vector2(220, 54) + Style.style_primary_button(start_button) + start_button.pressed.connect(_start_block) + add_child(start_button) func on_show(payload: Variant = null) -> void: @@ -63,8 +70,13 @@ func on_show(payload: Variant = null) -> void: var available := QuestionBankService.get_questions_for_system(selected_system).size() size_spin.max_value = max(1, available) size_spin.value = min(size_spin.value, available) + start_button.disabled = available == 0 + status_label.text = QuestionBankService.last_error if available == 0 else "" func _start_block() -> void: + if not QuestionBankService.has_questions(): + status_label.text = "Questions have not loaded from the API yet." + return BlockSessionService.start_block(selected_system, int(size_spin.value), mode_option.get_item_text(mode_option.selected)) main.show_screen("player") diff --git a/scripts/ui/study_map.gd b/scripts/ui/study_map.gd index d38d1df..65389b2 100644 --- a/scripts/ui/study_map.gd +++ b/scripts/ui/study_map.gd @@ -8,6 +8,8 @@ var zone_list: GridContainer func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 14) + if not QuestionBankService.questions_changed.is_connected(_refresh_zones): + QuestionBankService.questions_changed.connect(_refresh_zones) var title := Label.new() title.text = "Study Map" @@ -27,9 +29,31 @@ func _ready() -> void: func on_show(_payload: Variant = null) -> void: + _refresh_zones() + + +func _refresh_zones() -> void: + if zone_list == null: + return + for child in zone_list.get_children(): child.queue_free() + if QuestionBankService.is_loading: + var loading := Label.new() + loading.text = "Loading question bank..." + Style.set_label(loading, 20, Style.AMBER) + zone_list.add_child(loading) + return + + if not QuestionBankService.has_questions(): + var empty := Label.new() + empty.text = QuestionBankService.last_error if QuestionBankService.last_error != "" else "No API questions loaded." + empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(empty, 20, Style.RED) + zone_list.add_child(empty) + return + var systems: Array[String] = ["All Systems"] systems.append_array(QuestionBankService.get_systems()) var stats: Dictionary = ProfileService.get_profile().get("system_stats", {})