""" dt_callbacks.py --------------- 所有 Dash @callback 集中管理。 在 Dash app 啟動時此模組會被 import,所有 @callback 裝飾器即生效。 install_callbacks_once() 保留為空殼供相容呼叫。 Callback 清單: Fill 題 _cb_check_fill, _fill_hint_loading_on/off Choice/Multi _cb_check_choice, _choice_hint_loading_on/off Order 題 _cb_order_move, _cb_order_render, _cb_order_check Flow _cb_flow_append_steps Solution _cb_solution_toggle, _cb_solution_apply_open, _cb_solution_gate_view More Q _cb_moreq_open, _moreq_loading_on/off """ from __future__ import annotations from dash import callback, Input, Output, State, MATCH, ALL, ctx, no_update, Patch import dash_mantine_components as dmc from .dt_render_utils import ( render_option_label, _render_inline_math, DEFAULT_NORMALIZE, DEFAULT_TOL, _ensure_theme_colors, ) from .dt_math import eq_answer, _normalize_text from .dt_gemini import ( _ai_feedback_fill_gemini, _ai_feedback_choice_gemini, _ai_feedback_multi_gemini, _ai_generate_similar_quizzes_as_md, _parse_ai_md_to_quiz_nodes, ) from .dt_quiz import _quiz_missing_answer_notice, _feedback_child # --------------------------------------------------------------------------- # 工具 # --------------------------------------------------------------------------- def _as_set(v) -> set: if v is None: return set() if isinstance(v, (list, tuple, set)): return set(v) return {v} # --------------------------------------------------------------------------- # Fill 題 callbacks # --------------------------------------------------------------------------- @callback( Output({"type": "quiz-feedback-fill", "qid": MATCH}, "children"), Output({"type": "quiz-feedback-fill", "qid": MATCH}, "color"), Output({"type": "quiz-feedback-fill", "qid": MATCH}, "variant"), Output({"type": "quiz-feedback-fill", "qid": MATCH}, "style"), Output({"type": "quiz-pass-fill", "qid": MATCH}, "data"), Input({"type": "quiz-check", "qid": MATCH}, "n_clicks"), Input({"type": "quiz-hint", "qid": MATCH}, "n_clicks"), State({"type": "quiz-input", "qid": MATCH}, "value"), State({"type": "quiz-ans", "qid": MATCH}, "data"), State({"type": "quiz-pass-fill", "qid": MATCH}, "data"), prevent_initial_call=True, ) def _cb_check_fill(n_check, n_hint, user_input, meta, prev_pass): missing, msg = _quiz_missing_answer_notice({"kind": "fill", **(meta or {})}) if missing: return _feedback_child(msg), "yellow", "light", {"display": "block"}, False meta = meta or {} ops = meta.get("normalize") or DEFAULT_NORMALIZE tol = float(meta.get("tol", DEFAULT_TOL)) ui = _normalize_text(user_input or "", ops) trig = ctx.triggered_id is_hint = isinstance(trig, dict) and trig.get("type") == "quiz-hint" right_msg = meta.get("msg_right") or "✔ 正確!" wrong_msg = meta.get("msg_wrong") or "✘ 再想想~" if is_hint: if not ui: return _feedback_child("先輸入你的想法,我再給提示~"), "yellow", "light", {}, (prev_pass or False) ai_cfg = (meta.get("ai") or {}) if ai_cfg.get("enabled") and (ai_cfg.get("provider") or "gemini") == "gemini": fb = _ai_feedback_fill_gemini( prompt_text=meta.get("prompt_text", ""), user_input=ui, answers=meta.get("answers", []), rubric=ai_cfg.get("rubric", ""), model=ai_cfg.get("model", "gemini-2.5-flash"), ) return _feedback_child(fb.get("hint") or "再檢查定義與條件。"), "yellow", "light", {}, (prev_pass or False) return _feedback_child("(此題未開啟 AI 提示)"), "gray", "light", {}, (prev_pass or False) ok = eq_answer(user_input or "", meta.get("answers", []), ops, tol) if ok: return _feedback_child(right_msg), "green", "light", {}, True return _feedback_child(wrong_msg), "red", "light", {}, False @callback( Output({"type": "quiz-hint", "qid": MATCH}, "loading", allow_duplicate=True), Input( {"type": "quiz-hint", "qid": MATCH}, "n_clicks"), prevent_initial_call=True, ) def _fill_hint_loading_on(n_clicks): return bool(n_clicks) or no_update @callback( Output({"type": "quiz-hint", "qid": MATCH}, "loading", allow_duplicate=True), Input( {"type": "quiz-feedback-fill", "qid": MATCH}, "children"), prevent_initial_call=True, ) def _fill_hint_loading_off(_children): return False # --------------------------------------------------------------------------- # Choice / Multi 題 callbacks # --------------------------------------------------------------------------- @callback( Output({"type": "quiz-feedback-choice", "qid": MATCH}, "children"), Output({"type": "quiz-feedback-choice", "qid": MATCH}, "color"), Output({"type": "quiz-feedback-choice", "qid": MATCH}, "variant"), Output({"type": "quiz-feedback-choice", "qid": MATCH}, "style"), Output({"type": "quiz-pass-choice", "qid": MATCH}, "data"), Input( {"type": "quiz-submit", "qid": MATCH}, "n_clicks"), Input( {"type": "quiz-hint-choice", "qid": MATCH}, "n_clicks"), State( {"type": "quiz-choice", "qid": MATCH}, "value"), State( {"type": "quiz-ans", "qid": MATCH}, "data"), prevent_initial_call=True, ) def _cb_check_choice(n_submit, n_hint, val, meta): missing, msg = _quiz_missing_answer_notice(meta or {}) if missing: return _feedback_child(msg), "yellow", "light", {"display": "block"}, False meta = meta or {} qtype = meta.get("qtype", "choice") trig = ctx.triggered_id is_hint = isinstance(trig, dict) and trig.get("type") == "quiz-hint-choice" is_submit = isinstance(trig, dict) and trig.get("type") == "quiz-submit" selected = _as_set(val) hints_raw = meta.get("hints") or {} hints = {str(k).lower(): str(v).strip() for k, v in hints_raw.items() if str(v).strip()} has_hints = bool(hints) ai_cfg = (meta.get("ai") or {}) ai_provider = (ai_cfg.get("provider") or "gemini") ai_model = (ai_cfg.get("model") or "gemini-2.5-flash") ai_rubric = (ai_cfg.get("rubric") or "") ai_enabled = True if (qtype in ("multi", "choice") and not has_hints) else \ str(ai_cfg.get("enabled", "0")).lower() not in ("0", "false", "no", "off") # --- 提示 --- if is_hint: if not selected: return _feedback_child("先選一些選項,我再給提示~"), "yellow", "light", {"display": "block"}, False if qtype != "multi": if not val: return _feedback_child("先選一個選項,我再給提示~"), "yellow", "light", {"display": "block"}, False if val != (meta.get("answer") or ""): key = str(val).lower() h = hints.get(key) if h: return _feedback_child(h), "yellow", "light", {"display": "block"}, False if (not has_hints) and ai_enabled and ai_provider == "gemini": ai_hint = _ai_feedback_choice_gemini( prompt_text=meta.get("prompt_text", ""), selected_key=key, options=meta.get("options", []), answer_key=(meta.get("answer") or "").lower(), rubric=ai_rubric, model=ai_model, ) if ai_hint: return _feedback_child(ai_hint), "yellow", "light", {"display": "block"}, False return _feedback_child("再想想這個選項為什麼不對(從定義或條件下手)。"), "yellow", "light", {"display": "block"}, False return _feedback_child("你選得很接近(或已正確),可以按送出確認~"), "yellow", "light", {"display": "block"}, False # 多選 correct = set(meta.get("answers") or []) wrong = sorted(selected - correct) missing_keys = sorted(correct - selected) if wrong: if (not has_hints) and ai_enabled and ai_provider == "gemini": fb = _ai_feedback_multi_gemini( prompt_text=meta.get("prompt_text", ""), selected_keys=sorted(selected), options=meta.get("options", []), answer_keys=sorted(correct), rubric=ai_rubric, model=ai_model, ) if fb: return _feedback_child(fb), "yellow", "light", {"display": "block"}, False return _feedback_child("再回頭檢查題目條件:哪些選項其實不符合題意?"), "yellow", "light", {"display": "block"}, False msgs = [] for k in wrong[:2]: h = hints.get(str(k).lower()) msgs.append(f"{k}. {h}" if h else f"{k}. 這個選項想想它為什麼不符合題意。") return _feedback_child("\n".join(msgs)), "yellow", "light", {"display": "block"}, False if missing_keys: if (not has_hints) and ai_enabled and ai_provider == "gemini": fb = _ai_feedback_multi_gemini( prompt_text=meta.get("prompt_text", ""), selected_keys=sorted(selected), options=meta.get("options", []), answer_keys=sorted(correct), rubric=ai_rubric, model=ai_model, ) if fb: return _feedback_child(fb), "yellow", "light", {"display": "block"}, False return _feedback_child("再檢查題目條件:有沒有哪個關鍵字會讓某些選項必須一起成立?"), "yellow", "light", {"display": "block"}, False k = str(missing_keys[0]).lower() h = hints.get(k) return (_feedback_child(f"你可能漏選了某個關鍵選項,想想:{k}. {h}"), "yellow", "light", {"display": "block"}, False) if h else \ (_feedback_child("再檢查題意與每個選項的必要條件。"), "yellow", "light", {"display": "block"}, False) return _feedback_child("你選得很好,按送出確認~"), "yellow", "light", {"display": "block"}, False # --- 送出 --- if is_submit: if not selected: return _feedback_child("請至少選一個選項。"), "yellow", "light", {}, False if qtype == "multi": correct = set(meta.get("answers") or []) ok = (selected == correct) else: ok = (val == (meta.get("answer") or "")) if ok: msg = (meta.get("msg_right") or "✔ 正確!") if meta.get("explain"): msg += " " + meta["explain"] return _feedback_child(msg), "green", "light", {}, True if qtype == "multi" and (not has_hints) and ai_enabled and ai_provider == "gemini": correct = set(meta.get("answers") or []) fb = _ai_feedback_multi_gemini( prompt_text=meta.get("prompt_text", ""), selected_keys=sorted(selected), options=meta.get("options", []), answer_keys=sorted(correct), rubric=ai_rubric, model=ai_model, ) if fb: return _feedback_child(fb), "yellow", "light", {}, False return _feedback_child(meta.get("msg_wrong") or "✘ 再試試看~"), "red", "light", {}, False return _feedback_child(""), "gray", "transparent", {"display": "none"}, False @callback( Output({"type": "quiz-hint-choice", "qid": MATCH}, "loading", allow_duplicate=True), Input( {"type": "quiz-hint-choice", "qid": MATCH}, "n_clicks"), prevent_initial_call=True, ) def _choice_hint_loading_on(n_clicks): return bool(n_clicks) or no_update @callback( Output({"type": "quiz-hint-choice", "qid": MATCH}, "loading", allow_duplicate=True), Input( {"type": "quiz-feedback-choice", "qid": MATCH}, "children"), prevent_initial_call=True, ) def _choice_hint_loading_off(_children): return False # --------------------------------------------------------------------------- # Order 題 callbacks # --------------------------------------------------------------------------- @callback( Output({"type": "order-state", "qid": MATCH}, "data"), Input( {"type": "order-up", "qid": MATCH, "idx": ALL}, "n_clicks"), Input( {"type": "order-down", "qid": MATCH, "idx": ALL}, "n_clicks"), State( {"type": "order-state", "qid": MATCH}, "data"), prevent_initial_call=True, ) def _cb_order_move(up_clicks, down_clicks, order): if not order or not ctx.triggered_id: return no_update trig = ctx.triggered_id; pos = int(trig["idx"]); new = list(order) if trig["type"] == "order-up" and pos > 0: new[pos-1], new[pos] = new[pos], new[pos-1] elif trig["type"] == "order-down" and pos < len(new)-1: new[pos+1], new[pos] = new[pos], new[pos+1] return new @callback( Output({"type": "order-list", "qid": MATCH}, "children"), Input( {"type": "order-state", "qid": MATCH}, "data"), State( {"type": "order-steps", "qid": MATCH}, "data"), State( {"type": "order-list", "qid": MATCH}, "id"), ) def _cb_order_render(order, steps, this_id): if not order or not steps or not this_id: return [] from dash_iconify import DashIconify import dash_mantine_components as dmc qid = this_id["qid"] rows = [] for pos, idx in enumerate(order): rows.append(dmc.Group(gap="xs", align="center", wrap="nowrap", children=[ dmc.ActionIcon(DashIconify(icon="lucide:chevron-up"), id={"type": "order-up", "qid": qid, "idx": pos}, variant="light", size="sm"), dmc.ActionIcon(DashIconify(icon="lucide:chevron-down"), id={"type": "order-down", "qid": qid, "idx": pos}, variant="light", size="sm"), dmc.Badge(str(pos+1), variant="light"), _render_inline_math(steps[idx]), ])) return rows @callback( Output({"type": "quiz-feedback-order", "qid": MATCH}, "children"), Output({"type": "quiz-feedback-order", "qid": MATCH}, "color"), Output({"type": "quiz-pass-order", "qid": MATCH}, "data"), Input( {"type": "quiz-check", "qid": MATCH}, "n_clicks"), State( {"type": "order-state", "qid": MATCH}, "data"), State( {"type": "quiz-ans", "qid": MATCH}, "data"), prevent_initial_call=True, ) def _cb_order_check(_, order, meta): meta = meta or {}; ans = meta.get("answer") or []; ok = (order == ans) msg = (meta.get("msg_right") if ok else meta.get("msg_wrong")) or ("✔ 正確!" if ok else "✘ 再想想。") return render_option_label(msg), ("green" if ok else "red"), bool(ok) # --------------------------------------------------------------------------- # Flow callbacks # --------------------------------------------------------------------------- @callback( Output({"type": "flow-body", "flow": MATCH}, "children"), Input( {"type": "quiz-pass-fill", "qid": ALL}, "data"), Input( {"type": "quiz-pass-choice", "qid": ALL}, "data"), Input( {"type": "quiz-pass-order", "qid": ALL}, "data"), State( {"type": "quiz-pass-fill", "qid": ALL}, "id"), State( {"type": "quiz-pass-choice", "qid": ALL}, "id"), State( {"type": "quiz-pass-order", "qid": ALL}, "id"), State( {"type": "flow-steps", "flow": MATCH}, "data"), State( {"type": "flow-body", "flow": MATCH}, "children"), State( {"type": "flow-body", "flow": MATCH}, "id"), prevent_initial_call=True, ) def _cb_flow_append_steps(pass_fill, pass_choice, pass_order, ids_fill, ids_choice, ids_order, steps, current_children, body_id): from .dt_render_blocks import _make_flow_step_box qmap: dict = {} for pid, val in zip(ids_fill or [], pass_fill or []): if isinstance(pid, dict) and pid.get("qid"): qmap[pid["qid"]] = bool(val) for pid, val in zip(ids_choice or [], pass_choice or []): if isinstance(pid, dict) and pid.get("qid"): qmap[pid["qid"]] = bool(val) for pid, val in zip(ids_order or [], pass_order or []): if isinstance(pid, dict) and pid.get("qid"): qmap[pid["qid"]] = bool(val) fid = (body_id or {}).get("flow") steps = steps or [] current_children = current_children or [] rendered_n = len(current_children) patch = Patch() appended = False for idx in range(rendered_n, len(steps)): st = steps[idx] prev_qid = st.get("gate_from") if not ((idx == 0) or (prev_qid is None) or qmap.get(prev_qid, False)): break patch.append(_make_flow_step_box(fid, st)) appended = True return patch if appended else no_update # --------------------------------------------------------------------------- # Solution callbacks # --------------------------------------------------------------------------- @callback( Output({"type": "solution-open", "key": MATCH}, "data"), Input( {"type": "solution-toggle", "key": MATCH}, "n_clicks"), State( {"type": "solution-open", "key": MATCH}, "data"), State( {"type": "solution-after", "key": MATCH}, "data"), State( {"type": "quiz-pass-fill", "qid": ALL}, "id"), State( {"type": "quiz-pass-fill", "qid": ALL}, "data"), State( {"type": "quiz-pass-choice", "qid": ALL}, "id"), State( {"type": "quiz-pass-choice", "qid": ALL}, "data"), State( {"type": "quiz-pass-order", "qid": ALL}, "id"), State( {"type": "quiz-pass-order", "qid": ALL}, "data"), prevent_initial_call=True, ) def _cb_solution_toggle(n, is_open, after_qid, ids_f, vals_f, ids_c, vals_c, ids_o, vals_o): if not n: return no_update if after_qid: ok = False for ids, vals in ((ids_f, vals_f), (ids_c, vals_c), (ids_o, vals_o)): for i, pid in enumerate(ids or []): if isinstance(pid, dict) and pid.get("qid") == after_qid: if bool((vals or [None])[i]): ok = True break if ok: break if not ok: return is_open return not bool(is_open) @callback( Output({"type": "solution-collapse", "key": MATCH}, "opened"), Input( {"type": "solution-open", "key": MATCH}, "data"), ) def _cb_solution_apply_open(is_open): return bool(is_open) @callback( Output({"type": "solution-toggle", "key": MATCH}, "disabled"), Output({"type": "solution-hint", "key": MATCH}, "style"), Input( {"type": "solution-after", "key": MATCH}, "data"), Input( {"type": "quiz-pass-fill", "qid": ALL}, "id"), Input( {"type": "quiz-pass-fill", "qid": ALL}, "data"), Input( {"type": "quiz-pass-choice", "qid": ALL}, "id"), Input( {"type": "quiz-pass-choice", "qid": ALL}, "data"), Input( {"type": "quiz-pass-order", "qid": ALL}, "id"), Input( {"type": "quiz-pass-order", "qid": ALL}, "data"), ) def _cb_solution_gate_view(after_qid, ids_f, vals_f, ids_c, vals_c, ids_o, vals_o): # 沒有設定 after_qid 的解法,直接回傳不鎖定,跳過所有計算 if not after_qid: return False, {"display": "none"} ok = False for ids, vals in ((ids_f, vals_f), (ids_c, vals_c), (ids_o, vals_o)): for i, pid in enumerate(ids or []): if isinstance(pid, dict) and pid.get("qid") == after_qid: if bool((vals or [None])[i]): ok = True break if ok: break if ok: return False, {"display": "none"} return True, {"display": "block", "opacity": 0.7, "marginTop": "6px"} # --------------------------------------------------------------------------- # More Questions callbacks # --------------------------------------------------------------------------- @callback( Output({"type": "moreq-modal", "key": MATCH}, "opened"), Output({"type": "moreq-content", "key": MATCH}, "children"), Input( {"type": "moreq-open", "key": MATCH}, "n_clicks"), State( {"type": "moreq-modal", "key": MATCH}, "opened"), State( {"type": "moreq-prompt", "key": MATCH}, "data"), prevent_initial_call=True, ) def _cb_moreq_open(n, opened, prompt_text): from .dt_render_blocks import render_doc if not n: return no_update, no_update if opened: return False, no_update if not (prompt_text or "").strip(): return True, dmc.Alert("抓不到題目敘述:請在解法前面放 Example 標題或 當錨點。", color="yellow", variant="light") md = _ai_generate_similar_quizzes_as_md(prompt_text=prompt_text, qtype="choice") nodes = _parse_ai_md_to_quiz_nodes(md, default_qtype="choice") if not nodes: return True, dmc.Alert("AI 沒有產出可解析的題目格式。", color="red", variant="light") return True, render_doc(nodes) @callback( Output({"type": "moreq-open", "key": MATCH}, "loading", allow_duplicate=True), Input( {"type": "moreq-open", "key": MATCH}, "n_clicks"), State( {"type": "moreq-modal","key": MATCH}, "opened"), prevent_initial_call=True, ) def _moreq_loading_on(n_clicks, opened): if not n_clicks: return no_update if opened: return False return True @callback( Output({"type": "moreq-open", "key": MATCH}, "loading", allow_duplicate=True), Input( {"type": "moreq-content", "key": MATCH}, "children"), prevent_initial_call=True, ) def _moreq_loading_off(_children): return False # --------------------------------------------------------------------------- # 相容 shim # --------------------------------------------------------------------------- def install_callbacks_once() -> None: """Callbacks 在 import 時已全部掛載;此函式保留供舊版呼叫。""" return None