""" dt_math.py ---------- 數學文字正規化、SymPy 解析管線、答案等價比對。 依賴:sympy(純 Python,無 Dash 依賴) 公開 API: eq_answer(user_input, answers, ops, tol) → bool 主要入口 extract_param_function(eq, level_symbol) → (expr, symbol) extract_k_as_function(eq) → expr | None _normalize_text(s, ops) → str """ from __future__ import annotations import re import unicodedata import sympy as sp from sympy.parsing.sympy_parser import ( parse_expr, standard_transformations, implicit_multiplication_application, ) # --------------------------------------------------------------------------- # SymPy 設定 # --------------------------------------------------------------------------- _TRANSFORMS = standard_transformations + (implicit_multiplication_application,) x, y, z = sp.symbols("x y z") k, m, n, c, g = sp.symbols("k m n c g") XY = {x, y} PARAMS = {k, m, n, c, g} _SINGLE_LETTERS = set(list("xyzkmncg")) KNOWN_FUNCS = ["sin", "cos", "tan", "cot", "sec", "csc"] KNOWN_CONST = ["pi", "e"] _ALLOWED = { "x": x, "y": y, "z": z, "k": k, "m": m, "n": n, "c": c, "g": g, "pi": sp.pi, "e": sp.E, "sin": sp.sin, "cos": sp.cos, } _LOCALS = dict(_ALLOWED) # --------------------------------------------------------------------------- # 文字正規化 # --------------------------------------------------------------------------- def _std_text(s: str) -> str: """Unicode 正規化 + 常見符號替換(乘號、除號、減號等)。""" t = unicodedata.normalize("NFKC", (s or "").strip()) t = t.replace("×", "*").replace("∙", "*").replace("·", "*") t = t.replace("÷", "/").replace("⁄", "/").replace("/", "/") t = t.replace("−", "-").replace("–", "-").replace("—", "-") t = t.replace("^", "**") t = re.sub(r'(?<=\d),(?=\d)', "", t) # 移除數字中的千分位逗號 return t def _apply_text_ops(t: str, ops: set) -> str: if "lower" in ops: t = t.lower() if "nospace" in ops: t = re.sub(r"\s+", "", t) return t def _normalize_text(s: str, ops: list[str]) -> str: """依 ops 清單對字串做正規化(strip / lower / collapse_space / strip_dollars)。""" if not isinstance(s, str): s = "" if s is None else str(s) t = s if "strip" in ops: t = t.strip() if "lower" in ops: t = t.lower() if "collapse_space" in ops: t = re.sub(r"\s+", " ", t) if "strip_dollars" in ops: u = t.strip() if u.startswith("$$") and u.endswith("$$"): u = u[2:-2].strip() elif u.startswith("$") and u.endswith("$"): u = u[1:-1].strip() t = u return t # --------------------------------------------------------------------------- # SymPy 解析管線 # --------------------------------------------------------------------------- def _protect_funcs_and_consts(s: str) -> tuple[str, dict]: """把 sin/cos/pi 等先換成佔位符,避免後續拆字母時被錯誤分開。""" s2, mapping, i = s, {}, 0 for w in sorted(KNOWN_FUNCS + KNOWN_CONST, key=len, reverse=True): token = f"__W{i}__" i += 1 mapping[token] = w s2 = re.sub(rf"\b{w}\b", token, s2) s2 = re.sub(rf"(?<=\w){w}(?=\W|\w|\()", token, s2) return s2, mapping def _unprotect(s: str, mapping: dict) -> str: for token, w in mapping.items(): s = s.replace(token, w) return s def _insert_implicit_mul(s: str) -> str: """在相鄰 token 之間插入 *(隱式乘法)。""" s = re.sub(r"(\d)([A-Za-z_(])", r"\1*\2", s) s = re.sub(r"(\))([A-Za-z_(\d])", r"\1*\2", s) s = re.sub(r"([A-Za-z_\d])(\()", r"\1*\2", s) s = re.sub(r"([A-Za-z_])([A-Za-z_])", r"\1*\2", s) return s def _preprocess_math(s: str) -> str: """把數學文字轉成 SymPy-parseable 字串(補乘號、拆字母連寫)。""" s = _std_text(s) s = re.sub(r"\s+", "", s) for fn in sorted(KNOWN_FUNCS, key=len, reverse=True): s = re.sub(rf"(?<=[0-9A-Za-z\)]){fn}(?=\()", rf"*{fn}", s) for cc in sorted(KNOWN_CONST, key=len, reverse=True): s = re.sub(rf"(?<=[0-9A-Za-z\)]){cc}\b", rf"*{cc}", s) pat = rf"(? bool: """代數等價比對(差為 0 或常數倍)。""" ea, eb = _to_sympy(a), _to_sympy(b) if ea is None or eb is None: return False try: if isinstance(ea, sp.Equality): ea = sp.simplify(ea.lhs - ea.rhs) if isinstance(eb, sp.Equality): eb = sp.simplify(eb.lhs - eb.rhs) if sp.simplify(ea - eb) == 0: return True if ea.free_symbols == set() and eb.free_symbols == set(): return False try: ratio = sp.simplify(ea / eb) if ratio != 0 and ratio.free_symbols == set(): return True except Exception: pass return False except Exception: return False def _as_number(expr: str): """把字串轉成 SymPy 數值;含符號或失敗則回 None。""" try: e = _to_sympy(expr) if e is None: return None if getattr(e, "free_symbols", None) and e.free_symbols: return None if isinstance(e, sp.Equality): return None return sp.N(e) except Exception: return None def _numeric_close(a: str, b: str, tol: float = 1e-9) -> bool: ua, ub = _as_number(a), _as_number(b) if ua is None or ub is None: return False try: return abs(float(ua) - float(ub)) <= float(tol) except Exception: return False # --------------------------------------------------------------------------- # 主要對外 API # --------------------------------------------------------------------------- def eq_answer( user_input: str, answers: list[str], ops: list[str], tol: float = 1e-9, ) -> bool: """ 判斷使用者輸入是否等於任一正確答案。 ops 支援:sym(代數等價)/ numeric(數值等價)/ nospace / lower / strip_dollars """ ops_set = {o.strip().lower() for o in (ops or []) if o} u0 = _apply_text_ops(_std_text(user_input), ops_set) for a in answers or []: if u0 == _apply_text_ops(_std_text(a), ops_set): return True if "sym" in ops_set: for a in answers or []: if _symbolic_equal(u0, _std_text(a)): return True if "numeric" in ops_set: for a in answers or []: if _numeric_close(u0, _std_text(a), tol=tol): return True return False def extract_param_function( eq: sp.Expr | sp.Eq, level_symbol: sp.Symbol | None = None, ) -> tuple: """ 從方程式 eq 中解出「某個參數 = f(x,y)」的型態。 回傳 (f_xy, param_symbol);失敗回傳 (None, None)。 """ if not isinstance(eq, sp.Equality): eq = sp.Eq(eq, 0) expr0 = sp.simplify(eq.lhs - eq.rhs) symbols = expr0.free_symbols candidates = sorted((symbols - XY) & PARAMS, key=lambda s: s.name) if not candidates: return None, None target = level_symbol if level_symbol is not None else candidates[0] if target not in candidates: return None, None sol = sp.solve(sp.Eq(expr0, 0), target) if not sol: return None, None return sp.simplify(sol[0]), target def extract_k_as_function(eq: sp.Expr | sp.Eq): """保留原本 API:專門解出 k = f(x,y)。""" f_xy, _ = extract_param_function(eq, level_symbol=k) return f_xy