Add question bank mastery paths and gap questions
This commit is contained in:
@@ -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))),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user