Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| def _fill_hint_loading_on(n_clicks): | |
| return bool(n_clicks) or no_update | |
| def _fill_hint_loading_off(_children): | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # Choice / Multi 題 callbacks | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| def _choice_hint_loading_on(n_clicks): | |
| return bool(n_clicks) or no_update | |
| def _choice_hint_loading_off(_children): | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # Order 題 callbacks | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| 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 | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| def _cb_solution_apply_open(is_open): | |
| return bool(is_open) | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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 標題或 <!--problem--> 當錨點。", | |
| 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) | |
| def _moreq_loading_on(n_clicks, opened): | |
| if not n_clicks: return no_update | |
| if opened: return False | |
| return True | |
| def _moreq_loading_off(_children): | |
| return False | |
| # --------------------------------------------------------------------------- | |
| # 相容 shim | |
| # --------------------------------------------------------------------------- | |
| def install_callbacks_once() -> None: | |
| """Callbacks 在 import 時已全部掛載;此函式保留供舊版呼叫。""" | |
| return None | |