Dash-math_Database / layout /renderer /dt_callbacks.py
Mochi0622's picture
update files
d5d9742
Raw
History Blame Contribute Delete
22 kB
"""
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 標題或 <!--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)
@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