diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 2ab3d6b..f05103b 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -220,6 +220,9 @@ public class QuestionsController : ControllerBase return "explanation required"; if (req.ChoiceSelectionPercentages.Any(entry => entry.Value is < 0 or > 100)) return "choiceSelectionPercentages values must be between 0 and 100"; + var followupValidation = ValidateWrongAnswerFollowups(req); + if (followupValidation is not null) + return followupValidation; if (string.IsNullOrWhiteSpace(req.System)) return "system required"; if (string.IsNullOrWhiteSpace(req.Discipline)) @@ -230,6 +233,28 @@ public class QuestionsController : ControllerBase return null; } + private static string? ValidateWrongAnswerFollowups(UpsertQuestionRequest req) + { + var choiceIds = req.Choices.Select(c => c.Id).Where(id => !string.IsNullOrWhiteSpace(id)).ToHashSet(); + foreach (var entry in req.WrongAnswerFollowups) + { + if (!choiceIds.Contains(entry.Key)) + return "wrongAnswerFollowups keys must match a choice id"; + if (entry.Key == req.CorrectChoiceId) + return "wrongAnswerFollowups should only target incorrect choices"; + if (string.IsNullOrWhiteSpace(entry.Value.Prompt)) + return "wrongAnswerFollowups prompt required"; + if (string.IsNullOrWhiteSpace(entry.Value.TeachingPoint)) + return "wrongAnswerFollowups teachingPoint required"; + if (entry.Value.Choices.Count == 0) + return "wrongAnswerFollowups choices required"; + if (!entry.Value.Choices.Any(c => c.Id == entry.Value.CorrectChoiceId)) + return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id"; + } + + return null; + } + private string CurrentUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) diff --git a/api/QuestionsApi/Models/Question.cs b/api/QuestionsApi/Models/Question.cs index d1f2b99..9569493 100644 --- a/api/QuestionsApi/Models/Question.cs +++ b/api/QuestionsApi/Models/Question.cs @@ -16,6 +16,7 @@ public class Question public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; public Dictionary ChoiceExplanations { get; set; } = []; + public Dictionary WrongAnswerFollowups { get; set; } = []; public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; @@ -42,3 +43,11 @@ public class QuestionLab public string Unit { get; set; } = ""; public string Reference { get; set; } = ""; } + +public class WrongAnswerFollowup +{ + public string Prompt { get; set; } = ""; + public List Choices { get; set; } = []; + public string CorrectChoiceId { get; set; } = ""; + public string TeachingPoint { get; set; } = ""; +} diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 6aa279f..6b1e6fa 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -19,6 +19,7 @@ public class UpsertQuestionRequest public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; public Dictionary ChoiceExplanations { get; set; } = []; + public Dictionary WrongAnswerFollowups { get; set; } = []; public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 3348807..cf5d52a 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -10,6 +10,7 @@ public class QuestionResponse public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; public Dictionary ChoiceExplanations { get; set; } = []; + public Dictionary WrongAnswerFollowups { get; set; } = []; public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; @@ -33,6 +34,7 @@ public class QuestionResponse CorrectChoiceId = question.CorrectChoiceId, Explanation = question.Explanation, ChoiceExplanations = question.ChoiceExplanations, + WrongAnswerFollowups = question.WrongAnswerFollowups, ChoiceSelectionPercentages = BuildChoiceSelectionPercentages(question, liveChoiceSelectionPercentages), Discipline = question.Discipline, System = question.System, diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index 912d144..16857be 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -136,6 +136,7 @@ public class QuestionStore CorrectChoiceId = req.CorrectChoiceId.Trim(), Explanation = req.Explanation.Trim(), ChoiceExplanations = req.ChoiceExplanations, + WrongAnswerFollowups = req.WrongAnswerFollowups, ChoiceSelectionPercentages = req.ChoiceSelectionPercentages, Discipline = req.Discipline.Trim(), System = req.System.Trim(), diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index fa9a416..2f04bff 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -374,6 +374,7 @@ func _normalize_api_question(item: Variant) -> Dictionary: "correct_choice_id": str(item.get("correctChoiceId", item.get("correct_choice_id", ""))), "explanation": str(item.get("explanation", "")), "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", "")), diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 7bd1ba3..55c85ab 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -71,6 +71,10 @@ func _build_ui() -> void: user_label = Label.new() Style.set_label(user_label, 15, Style.CYAN) user_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + user_label.mouse_filter = Control.MOUSE_FILTER_STOP + user_label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + user_label.tooltip_text = "Open profile" + user_label.gui_input.connect(_on_user_label_gui_input) header.add_child(user_label) var screens_container := MarginContainer.new() @@ -246,3 +250,14 @@ func _open_profile_from_menu() -> void: if BlockSessionService.block_started: BlockSessionService.finish_block() show_screen("profile") + + +func _on_user_label_gui_input(event: InputEvent) -> void: + if not AuthState.is_logged_in: + return + var mouse_event := event as InputEventMouseButton + if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT or not mouse_event.pressed: + return + _close_app_menu() + if current_screen_name != "profile": + show_screen("profile") diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index edf828f..239a98d 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -7,8 +7,13 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 0.6 var main: Control var choice_buttons: Array[Button] = [] var choice_percent_labels: Array[Label] = [] +var choice_followup_panels: Array[PanelContainer] = [] +var choice_followup_contents: Array[VBoxContainer] = [] var eliminated: Array = [] var locked_wrong_choice_ids: Array = [] +var active_followup_choice_id := "" +var followup_required := false +var followup_answered := false var flagged := false var selected_choice := "" var current_answer := {} @@ -159,6 +164,23 @@ func _ready() -> void: button.add_child(percent_label) choice_percent_labels.append(percent_label) + var followup_panel := PanelContainer.new() + followup_panel.visible = false + followup_panel.mouse_filter = Control.MOUSE_FILTER_STOP + followup_panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + followup_panel.offset_left = 16 + followup_panel.offset_top = 42 + followup_panel.offset_right = -16 + followup_panel.offset_bottom = -10 + followup_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("26151a"), Style.RED)) + button.add_child(followup_panel) + choice_followup_panels.append(followup_panel) + + var followup_content := VBoxContainer.new() + followup_content.add_theme_constant_override("separation", 6) + followup_panel.add_child(followup_content) + choice_followup_contents.append(followup_content) + question_content.add_child(button) explanation_label = RichTextLabel.new() @@ -242,6 +264,7 @@ func _load_question() -> void: _reset_wrong_feedback() eliminated.clear() locked_wrong_choice_ids.clear() + _clear_followups() flagged = false selected_choice = "" current_answer = {} @@ -300,7 +323,7 @@ func _load_question() -> void: func _select_choice(index: int) -> void: - if pending_correct_advance: + if pending_correct_advance or followup_required: return var question := BlockSessionService.get_current_question() var choices: Array = question.get("choices", []) @@ -335,7 +358,7 @@ func _skip_current_question() -> void: func _choice_gui_input(event: InputEvent, index: int) -> void: - if pending_correct_advance: + if pending_correct_advance or followup_required: return var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed: @@ -361,7 +384,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void: func _toggle_eliminate_selected() -> void: - if pending_correct_advance or selected_choice == "" or not current_answer.is_empty(): + if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty(): return var question := BlockSessionService.get_current_question() var choices: Array = question.get("choices", []) @@ -395,9 +418,13 @@ func _submit_or_next() -> void: _trigger_correct_feedback() return _lock_wrong_choice(attempted_choice) + _show_wrong_answer_followup(attempted_choice) selected_choice = "" current_answer = {} next_button.text = "Submit" + next_button.disabled = true + if skip_button != null: + skip_button.disabled = true _trigger_wrong_feedback() _apply_saved_choice_state() return @@ -666,10 +693,17 @@ func _apply_saved_choice_state() -> void: if pending_correct_advance: _apply_correct_answer_preview() + if followup_required: + for index in range(choices.size()): + var id := str(choices[index].get("id", "")) + choice_buttons[index].disabled = id != active_followup_choice_id + func _set_choice_button_text(index: int, choice: Dictionary) -> void: var choice_id := str(choice.get("id", "")) var choice_text := str(choice.get("text", "")) + var has_visible_followup := index < choice_followup_panels.size() and choice_followup_panels[index].visible + choice_buttons[index].custom_minimum_size = Vector2(0, 190) if has_visible_followup or choice_id == active_followup_choice_id else Vector2(0, 64) _set_choice_percent_label(index, "") if locked_wrong_choice_ids.has(choice_id): var percentage := _get_choice_selection_percentage(choice_id) @@ -729,6 +763,127 @@ func _on_choice_percentages_updated(question_id: String, percentages: Dictionary _apply_saved_choice_state() +func _show_wrong_answer_followup(choice_id: String) -> void: + active_followup_choice_id = choice_id + followup_required = true + followup_answered = false + var index := _choice_index_for_id(choice_id) + if index < 0: + _unlock_after_followup() + return + _build_followup_panel(index, choice_id) + + +func _build_followup_panel(index: int, choice_id: String) -> void: + _clear_followup_content(index) + var panel := choice_followup_panels[index] + var content := choice_followup_contents[index] + var followup := _get_followup_for_choice(choice_id) + + var prompt := Label.new() + prompt.text = str(followup.get("prompt", "What clue in the stem should make this answer less likely?")) + prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(prompt, 14, Style.TEXT) + content.add_child(prompt) + + var choices: Array = followup.get("choices", []) + if choices.is_empty(): + choices = [{"id": "A", "text": "Review the stem clue"}] + followup["correct_choice_id"] = "A" + followup["teaching_point"] = "Review the stem for the clue that makes this answer less appropriate before trying again." + for option in choices: + if typeof(option) != TYPE_DICTIONARY: + continue + var option_button := Button.new() + option_button.text = "%s. %s" % [str(option.get("id", "")), str(option.get("text", ""))] + option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + option_button.custom_minimum_size = Vector2(0, 32) + Style.style_chip(option_button, Style.CYAN) + option_button.pressed.connect(func(option_id := str(option.get("id", "")), selected_button := option_button, followup_data := followup): + _answer_followup(option_id, selected_button, followup_data) + ) + content.add_child(option_button) + + panel.visible = true + + +func _answer_followup(option_id: String, selected_button: Button, followup: Dictionary) -> void: + if not followup_required or followup_answered: + return + followup_answered = true + var correct_id := str(followup.get("correct_choice_id", "")) + var is_correct := option_id == correct_id + Style.style_chip(selected_button, Style.LIME if is_correct else Style.RED) + + var index := _choice_index_for_id(active_followup_choice_id) + if index >= 0: + for child in choice_followup_contents[index].get_children(): + if child is Button: + child.disabled = true + + var teaching := Label.new() + teaching.text = str(followup.get("teaching_point", "Review the stem clue before choosing again.")) + teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(teaching, 14, Style.AMBER) + choice_followup_contents[index].add_child(teaching) + + _unlock_after_followup() + + +func _unlock_after_followup() -> void: + followup_required = false + next_button.disabled = false + if skip_button != null: + skip_button.disabled = false + _apply_saved_choice_state() + + +func _clear_followups() -> void: + active_followup_choice_id = "" + followup_required = false + followup_answered = false + for index in range(choice_followup_panels.size()): + _clear_followup_content(index) + choice_followup_panels[index].visible = false + + +func _clear_followup_content(index: int) -> void: + if index < 0 or index >= choice_followup_contents.size(): + return + for child in choice_followup_contents[index].get_children(): + choice_followup_contents[index].remove_child(child) + child.queue_free() + + +func _get_followup_for_choice(choice_id: String) -> Dictionary: + var question := BlockSessionService.get_current_question() + var followups: Dictionary = question.get("wrong_answer_followups", {}) + if followups.has(choice_id) and typeof(followups[choice_id]) == TYPE_DICTIONARY: + var authored: Dictionary = followups[choice_id] + return { + "prompt": str(authored.get("prompt", "What clue in the stem should make this answer less likely?")), + "choices": authored.get("choices", []), + "correct_choice_id": str(authored.get("correctChoiceId", authored.get("correct_choice_id", ""))), + "teaching_point": str(authored.get("teachingPoint", authored.get("teaching_point", "Review the stem clue before choosing again."))) + } + + return { + "prompt": "What clue in the stem should make this answer less likely?", + "choices": [{"id": "A", "text": "Review the stem clue"}], + "correct_choice_id": "A", + "teaching_point": "Review the stem for the clue that makes this answer less appropriate before trying again." + } + + +func _choice_index_for_id(choice_id: String) -> int: + var question := BlockSessionService.get_current_question() + var choices: Array = question.get("choices", []) + for index in range(choices.size()): + if str(choices[index].get("id", "")) == choice_id: + return index + return -1 + + func _on_question_rating_updated(question_id: String, question_elo: int, _rating: Dictionary) -> void: if str(BlockSessionService.get_current_question().get("id", "")) != question_id: return