Add question bank mastery paths and gap questions

This commit is contained in:
hz
2026-07-31 23:46:46 -05:00
parent 012d0cf196
commit 8c439c6294
28 changed files with 1409 additions and 121 deletions
+36 -3
View File
@@ -53,6 +53,11 @@ 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:
@@ -107,7 +112,8 @@ func _ready() -> void:
_process_attempt_queue()
func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = [], require_media: bool = false) -> void:
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
@@ -115,6 +121,11 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar
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
@@ -144,11 +155,17 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar
"Authorization: Bearer %s" % questions_request_token
])
var payload := {
"limit": 1,
"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.")
@@ -177,6 +194,12 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
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):
@@ -200,6 +223,10 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
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
@@ -256,6 +283,11 @@ func clear_questions() -> void:
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()
@@ -337,7 +369,7 @@ func get_questions_for_system(system_name: String) -> Array:
var filtered: Array = []
for question in questions:
if str(question.get("system", "")) == system_name:
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
@@ -688,6 +720,7 @@ func _normalize_api_question(item: Variant) -> Dictionary:
"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))),
+5 -1
View File
@@ -24,6 +24,7 @@ var cleared_count := 0
var retained_count := 0
var outbox: Array = []
var active_account_key := ""
var step3_system := ""
func _ready() -> void:
@@ -48,7 +49,8 @@ func _ready() -> void:
_on_auth_changed()
func start_pass() -> void:
func start_pass(requested_step3_system: String = "") -> void:
step3_system = requested_step3_system
excluded_question_ids.clear()
reviewed_count = 0
cleared_count = 0
@@ -68,6 +70,8 @@ func load_next() -> void:
"excludeExternalIds": excluded_question_ids,
"supportsChoicePools": true
}
if not step3_system.is_empty():
payload["step3System"] = step3_system
var error := next_request.request(
"%s/next" % api_url,
_headers(),
+17 -3
View File
@@ -9,6 +9,8 @@ var size_spin: SpinBox
var mode_option: OptionButton
var status_label: Label
var start_button: Button
var requested_count := 10
var targeted_setup := false
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -67,7 +69,16 @@ func _ready() -> void:
func on_show(payload: Variant = null) -> void:
selected_system = str(payload) if payload != null else "All Systems"
if typeof(payload) == TYPE_DICTIONARY:
targeted_setup = true
selected_system = str(payload.get("step3_system", "All Systems"))
requested_count = int(payload.get("question_count", 10))
size_spin.value = requested_count
QuestionBankService.load_targeted_questions(selected_system, requested_count,
int(payload.get("difficulty_min", 0)), int(payload.get("difficulty_max", 0)))
else:
targeted_setup = false
selected_system = str(payload) if payload != null else "All Systems"
_refresh_setup()
@@ -77,14 +88,17 @@ func _refresh_setup() -> void:
system_label.text = "Zone: %s" % selected_system
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)
if not QuestionBankService.is_loading and available > 0:
size_spin.value = min(requested_count, available) if targeted_setup else min(size_spin.value, available)
targeted_setup = false
start_button.disabled = available == 0 or QuestionBankService.is_loading
if QuestionBankService.is_loading:
status_label.text = "Question API unavailable. Retrying in %d seconds..." % QuestionBankService.next_retry_seconds if QuestionBankService.load_state == "retrying" else "Loading question bank..."
elif available == 0:
status_label.text = QuestionBankService.last_error if QuestionBankService.last_error != "" else "No questions are available for this zone yet."
else:
status_label.text = "%d questions available." % available
status_label.text = "%d questions available.%s" % [available,
" The requested difficulty range was broadened." if QuestionBankService.targeted_fallback_used else ""]
func _start_block() -> void:
+150 -5
View File
@@ -33,6 +33,7 @@ var accuracy_progress_bar: ProgressBar
var pending_review_launch := false
var review_queue_service: Variant
var trend_mode := "rating"
var mastery_rows: Array[BoxContainer] = []
func _ready() -> void:
@@ -63,6 +64,7 @@ func _build() -> void:
metric_values.clear()
metric_details.clear()
metric_trends.clear()
mastery_rows.clear()
scroll = ScrollContainer.new()
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
@@ -131,7 +133,7 @@ func _build() -> void:
performance.add_child(trend_chart)
performance.add_child(HSeparator.new())
var system_header := HBoxContainer.new()
system_header.add_child(_section_title("Performance by system"))
system_header.add_child(_section_title("System mastery path"))
var header_space := Control.new()
header_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL
system_header.add_child(header_space)
@@ -276,7 +278,7 @@ func _render_accuracy_card(data: Dictionary) -> void:
accuracy_progress_bar.value = accuracy
func _render_systems(systems_value: Array) -> void:
func _render_systems_legacy(systems_value: Array) -> void:
_clear(system_list)
var systems := systems_value.duplicate(true)
if systems.is_empty():
@@ -331,7 +333,7 @@ func _render_systems(systems_value: Array) -> void:
system_list.add_child(row)
func _render_focus(systems: Array) -> void:
func _render_focus_legacy(systems: Array) -> void:
_clear(focus_content)
focus_content.add_child(_section_title("Study focus"))
if systems.is_empty():
@@ -355,6 +357,143 @@ func _render_focus(systems: Array) -> void:
focus_content.add_child(practice)
func _render_systems(systems: Array) -> void:
_clear(system_list)
mastery_rows.clear()
if systems.is_empty():
system_list.add_child(_label("Answer questions to begin your mastery path.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
for item in systems:
var container := VBoxContainer.new()
container.add_theme_constant_override("separation", 6)
var row := BoxContainer.new()
row.vertical = false
mastery_rows.append(row)
row.add_theme_constant_override("separation", 10)
var name_label := _label(str(item.get("system", "General")), 13, Brand.text_color(UIThemeService.is_dark_mode()), true)
name_label.custom_minimum_size.x = 190
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(name_label)
var state := str(item.get("masteryState", "unseen"))
var state_label := _label(state.capitalize(), 11, _mastery_color(state), true)
state_label.custom_minimum_size.x = 74
row.add_child(state_label)
var bar := ProgressBar.new()
bar.show_percentage = false
bar.value = int(item.get("masteryScore", 0))
bar.custom_minimum_size = Vector2(80, 7)
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_style_progress(bar, _mastery_color(state))
row.add_child(bar)
var percentage := _label("%d%%" % int(item.get("masteryScore", 0)) if int(item.get("distinctQuestions", 0)) > 0 else "--", 14, Brand.ROYAL_BLUE, true)
percentage.custom_minimum_size.x = 48
row.add_child(percentage)
var action: Dictionary = item.get("recommendedAction", {})
var action_button := _button(str(action.get("label", "Practice")))
action_button.custom_minimum_size.x = 155
action_button.pressed.connect(_launch_mastery_action.bind(item))
row.add_child(action_button)
container.add_child(row)
var evidence := "%d distinct | %d difficulty 4+ successful" % [int(item.get("distinctQuestions", 0)), int(item.get("difficulty4PlusSuccessful", 0))]
if int(item.get("pendingReviews", 0)) > 0:
evidence += " | %d review(s) pending" % int(item.get("pendingReviews", 0))
elif bool(item.get("refreshDue", false)):
evidence += " | Refresh due"
container.add_child(_label(evidence, 11, Brand.muted_color(UIThemeService.is_dark_mode())))
var requirements := _label(_mastery_requirement(item), 11, Brand.muted_color(UIThemeService.is_dark_mode()))
requirements.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
requirements.visible = false
var requirement_toggle := Button.new()
requirement_toggle.text = "Show requirements"
requirement_toggle.flat = true
requirement_toggle.alignment = HORIZONTAL_ALIGNMENT_LEFT
requirement_toggle.pressed.connect(func() -> void:
requirements.visible = not requirements.visible
requirement_toggle.text = "Hide requirements" if requirements.visible else "Show requirements")
container.add_child(requirement_toggle)
container.add_child(requirements)
system_list.add_child(container)
func _render_focus(systems: Array) -> void:
_clear(focus_content)
focus_content.add_child(_section_title("Study focus"))
if systems.is_empty():
focus_content.add_child(_label("Complete a few questions to receive a focused recommendation.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
var weakest: Dictionary = systems[0]
for item in systems:
if _is_higher_focus(item, weakest):
weakest = item
focus_content.add_child(_label("Recommended focus", 12, Brand.muted_color(UIThemeService.is_dark_mode())))
focus_content.add_child(_label(str(weakest.get("system", "General")), 23, Brand.PURPLE, true))
focus_content.add_child(_label("%s | %d%% mastery across %d questions" % [str(weakest.get("masteryState", "unseen")).capitalize(), int(weakest.get("masteryScore", 0)), int(weakest.get("distinctQuestions", 0))], 13, Brand.text_color(UIThemeService.is_dark_mode())))
var progress := ProgressBar.new()
progress.show_percentage = false
progress.value = int(weakest.get("masteryScore", 0))
progress.custom_minimum_size.y = 8
_style_progress(progress, Brand.PURPLE)
focus_content.add_child(progress)
var action: Dictionary = weakest.get("recommendedAction", {})
var practice := _button(str(action.get("label", "Start learning")))
practice.pressed.connect(_launch_mastery_action.bind(weakest))
focus_content.add_child(practice)
func _mastery_color(state: String) -> Color:
match state:
"mastered": return Brand.SUCCESS
"strong": return Brand.TEAL
"practicing": return Brand.ROYAL_BLUE
"learning": return Brand.AMBER
_: return Brand.muted_color(UIThemeService.is_dark_mode())
func _mastery_requirement(item: Dictionary) -> String:
match str(item.get("masteryState", "unseen")):
"unseen": return "Answer your first question in this system to begin the path."
"learning": return "Reach 5 distinct questions to enter Practicing."
"practicing": return "Strong requires 15 distinct questions and at least 75% mastery."
"strong": return "Mastered requires 25 distinct questions, 85% mastery, 5 successful difficulty 4+ questions, and no unresolved reviews."
_:
return "Complete a difficulty 4-5 refresh when due to keep this mastery current." if bool(item.get("refreshDue", false)) else "This system currently meets every mastery requirement."
func _mastery_priority(item: Dictionary) -> int:
if int(item.get("pendingReviews", 0)) > 0:
return -1000 + int(item.get("masteryScore", 0))
var ranks := {"unseen": 0, "learning": 1, "practicing": 2, "strong": 3, "mastered": 4}
return int(ranks.get(str(item.get("masteryState", "unseen")), 0)) * 100 + int(item.get("masteryScore", 0))
func _is_higher_focus(candidate: Dictionary, current: Dictionary) -> bool:
var candidate_priority := _mastery_priority(candidate)
var current_priority := _mastery_priority(current)
if candidate_priority != current_priority:
return candidate_priority < current_priority
var candidate_date := str(candidate.get("lastEvidenceUtc", ""))
var current_date := str(current.get("lastEvidenceUtc", ""))
if candidate_date.is_empty():
return not current_date.is_empty()
return not current_date.is_empty() and candidate_date < current_date
func _launch_mastery_action(item: Dictionary) -> void:
var action: Dictionary = item.get("recommendedAction", {})
var system_name := str(item.get("system", ""))
if str(action.get("type", "practice")) == "review":
_start_review_queue(system_name)
return
main.show_screen("setup", {
"step3_system": system_name,
"question_count": int(action.get("questionCount", 10)),
"difficulty_min": int(action.get("difficultyMin", 0)) if action.get("difficultyMin") != null else 0,
"difficulty_max": int(action.get("difficultyMax", 0)) if action.get("difficultyMax") != null else 0
})
func _render_review(data: Dictionary) -> void:
_clear(review_list)
review_list.add_child(_section_title("Review queue"))
@@ -495,12 +634,12 @@ func _review_action(text: String, icon_name: String, accent: Color, route: Strin
return button
func _start_review_queue() -> void:
func _start_review_queue(step3_system: String = "") -> void:
if pending_review_launch:
return
pending_review_launch = true
_show_loading_message("Preparing your review queue...")
review_queue_service.start_pass()
review_queue_service.start_pass(step3_system)
_render_review(DashboardService.data)
@@ -673,6 +812,12 @@ func _update_responsive() -> void:
lower_grid.columns = 1 if compact else 2
if trend_chart != null:
trend_chart.custom_minimum_size.x = 300.0 if compact else 520.0
var narrow := size.x < 700.0
for row in mastery_rows:
row.vertical = narrow
for child in row.get_children():
if child is Button:
child.custom_minimum_size.x = 0.0 if narrow else 155.0
func _clear(container: Container) -> void: