""" dt_gemini.py ------------ Gemini AI 用戶端、JSON 解析工具、題目生成、AI 批改回饋。 依賴:無 Dash 元件依賴(可獨立測試) 公開 API: _gem_call(txt, model) → str _gem_json(model, system, schema, payload) → dict _ai_generate_similar_quizzes_as_md(...) → str _parse_ai_md_to_quiz_nodes(...) → List[dict] _ai_feedback_fill_gemini(...) → dict _ai_feedback_choice_gemini(...) → str _ai_feedback_multi_gemini(...) → dict """ from __future__ import annotations import os, json, re, traceback from pathlib import Path from dotenv import load_dotenv # --------------------------------------------------------------------------- # Gemini client 初始化(單一來源) # --------------------------------------------------------------------------- import google.genai as genai from google.genai import types as genai_types DOTENV_PATH = Path(__file__).resolve().parents[2] / ".env" # layout/renderer/ → 上兩層 = 專案根目錄 load_dotenv(dotenv_path=DOTENV_PATH, override=True) _JSON_RE = re.compile(r"\{.*\}", re.S) def _get_api_key() -> str: raw = (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "").strip() return raw.strip('"').strip("'") _API_KEY = _get_api_key() print("[dotenv] path =", DOTENV_PATH, "| exists =", DOTENV_PATH.exists()) print("[dotenv] GEMINI_API_KEY set:", bool(_API_KEY)) _gem_client = genai.Client(api_key=_API_KEY) if _API_KEY else None # --------------------------------------------------------------------------- # 基礎工具 # --------------------------------------------------------------------------- def _norm_model(m: str) -> str: m = (m or "").strip() return m if m.startswith("models/") else (f"models/{m}" if m else "models/gemini-2.5-flash") def _s(x) -> str: """safe strip:None → '',其他型別 → str().strip()""" if x is None: return "" if isinstance(x, str): return x.strip() return str(x).strip() def _first_json(text: str): """從文字中抽出第一段 {...} 並解析為 dict;失敗回 None。""" m = _JSON_RE.search(text or "") if not m: return None try: return json.loads(m.group(0)) except Exception: return None def _ai_log_fail(tag: str, exc: Exception, raw_text: str | None = None) -> None: print("\n" + "=" * 90, flush=True) print(f"[AI FAIL] {tag}", flush=True) print(f"[AI FAIL] {type(exc).__name__}: {exc!r}", flush=True) if raw_text is not None: raw_text = raw_text or "" print("[AI FAIL] --- raw response (head 2000 chars) ---", flush=True) print(raw_text[:2000], flush=True) if len(raw_text) > 2000: print("[AI FAIL] --- raw response (tail 400 chars) ---", flush=True) print(raw_text[-400:], flush=True) traceback.print_exc() print("=" * 90 + "\n", flush=True) # --------------------------------------------------------------------------- # Gemini 呼叫 # --------------------------------------------------------------------------- def _gem_call(txt: str, model: str | None = None) -> str: """最基本的文字呼叫,無 schema,回傳純文字。""" global _gem_client if not _gem_client: print("[gemini] no API key; skip call") return "" try: r = _gem_client.models.generate_content( model=_norm_model(model or "models/gemini-2.5-flash"), contents=[{"role": "user", "parts": [{"text": txt}]}], ) return (getattr(r, "text", "") or "").strip() except Exception as e: print("[gemini] call failed:", e) return "" def _repair_json_newlines(s: str) -> str: """修復 JSON 字串值內的真換行,避免 json.loads 爆掉。""" if not s: return s out, in_str, esc = [], False, False for ch in s: if not in_str: out.append(ch) if ch == '"': in_str = True; esc = False continue if esc: out.append(ch); esc = False; continue if ch == '\\': out.append(ch); esc = True; continue if ch == '"': out.append(ch); in_str = False; continue if ch == '\n': out.append('\\n') elif ch == '\r': out.append('\\r') elif ch == '\t': out.append('\\t') else: out.append(ch) return "".join(out) def _loads_json_robust(text: str): """先直接 loads,失敗後修復換行再試。""" m = _JSON_RE.search(text or "") s = m.group(0) if m else (text or "") s = s.strip() try: return json.loads(s) except json.JSONDecodeError: pass try: return json.loads(_repair_json_newlines(s)) except Exception: return None def _gem_json( model: str, system: str, schema: dict, payload: dict, temperature: float = 0.2, max_tokens: int = 256, ) -> dict: """JSON schema 模式呼叫,自動 fallback 至無 schema 模式。""" if not _gem_client or not _API_KEY: print("[gemini] NO_KEY", flush=True) return {"_error": "NO_KEY"} DEBUG_AI = str(os.getenv("DEBUG_AI", "0")).lower() in ("1", "true", "yes", "on") if DEBUG_AI: print("\n" + "=" * 90, flush=True) print(f"[gemini][REQ] model = {model}", flush=True) print(f"[gemini][REQ] system =\n{system}", flush=True) print("=" * 90 + "\n", flush=True) cfg_schema = genai_types.GenerateContentConfig( system_instruction=system, response_mime_type="application/json", response_schema=schema, temperature=temperature, max_output_tokens=max_tokens, ) # --- 第一次嘗試:JSON schema mode --- try: resp = _gem_client.models.generate_content( model=_norm_model(model), contents=json.dumps(payload, ensure_ascii=False), config=cfg_schema, ) text = getattr(resp, "text", "") or "" if not text and getattr(resp, "candidates", None): parts = resp.candidates[0].content.parts text = getattr(parts[0], "text", "") if parts else "" try: return json.loads(text or "{}") except json.JSONDecodeError as je: _ai_log_fail("JSON decode failed (schema mode)", je, text) return {"_error": "BAD_JSON", "_why": str(je), "_raw_head": (text or "")[:2000]} except Exception as e1: _ai_log_fail("Gemini call failed (schema mode)", e1) # --- 第二次嘗試:無 schema,強制輸出 JSON --- cfg_plain = genai_types.GenerateContentConfig( system_instruction=system + " Output ONLY JSON with the requested fields.", temperature=temperature, max_output_tokens=max_tokens, ) try: resp2 = _gem_client.models.generate_content( model=_norm_model(model), contents=json.dumps(payload, ensure_ascii=False), config=cfg_plain, ) text2 = getattr(resp2, "text", "") or "" if not text2 and getattr(resp2, "candidates", None): parts2 = resp2.candidates[0].content.parts text2 = getattr(parts2[0], "text", "") if parts2 else "" obj = _loads_json_robust(text2 or "") if obj is not None: return obj _ai_log_fail("JSON decode failed (robust repair also failed)", Exception("BAD_JSON"), text2) return {"_error": "BAD_JSON", "_why": "robust repair failed", "_raw_head": (text2 or "")[:2000]} except Exception as e2: _ai_log_fail("Gemini call failed (no-schema fallback)", e2) return {"_error": "CALL_FAIL", "_why": str(e2)} # --------------------------------------------------------------------------- # AI 題目生成 # --------------------------------------------------------------------------- _OPT_LINE_RE = re.compile(r'^\s*([a-dA-D])\s*[\.]\s*(.+?)\s*$') _RE_AI_CHOICE = re.compile(r'(?P.*?)', re.S | re.I) _RE_AI_FILL = re.compile(r'(?P.*?)', re.S | re.I) _Q_ATTR = re.compile(r'([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"([^"]*)"' r'|([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*([^\s">]+)') def _attrs_to_dict_local(s: str) -> dict: out: dict = {} if not s: return out for m in _Q_ATTR.finditer(s): k = (m.group(1) or m.group(3)).strip() v = (m.group(2) or m.group(4)).strip() out[k] = v return out def _parse_options_md(s: str) -> list[dict]: out = [] for line in (s or "").splitlines(): m = _OPT_LINE_RE.match(line) if m: out.append({"key": m.group(1).lower(), "text": m.group(2)}) return out # DEFAULT_NORMALIZE 由 dt_render_utils 提供,此處需要直接用 DEFAULT_NORMALIZE = ["sym", "numeric", "nospace"] def _ai_generate_similar_quizzes_as_md( prompt_text: str, qtype: str, model: str = "gemini-2.5-flash", ) -> str: """呼叫 Gemini,生成 3 題類似題(回傳 Markdown 格式字串)。""" system = ( "你是數學教材出題助教。請根據『原題』生成 3 題同概念同難度練習題。\n" "⚠ 只輸出『題庫註解格式』,不要輸出 JSON、不要 code fence、不要多餘解釋。\n\n" "格式(單選):\n" "題目(可含 $...$,允許多行)\n" '\n' "a. 選項\nb. 選項\nc. 選項\nd. 選項\n\n\n" "格式(填空):\n" "題目(可含 $...$,允許多行)\n" '\n' "\n\n" "規則:\n" "1) 一共輸出 3 題。\n2) 選項請用 a./b./c./d.。\n" "3) 內容避免半形雙引號,用「」或單引號。\n" "4) 數學式只要一組 $ 字號。\n" "5) 除法要用 \\frac{}{}。\n" "6) 數學式要用 $ 包起來,禁止用空格。" ) ask = system + "\n\n[原題]\n" + (prompt_text or "") + f"\n\n[題型]\n{qtype}" print("\n===== [moreq] ask to gemini BEGIN =====", flush=True) print(ask, flush=True) print("===== [moreq] ask to gemini END =====\n", flush=True) return (_gem_call(ask, model=model) or "").strip() def _prompt_md_to_runs(md: str) -> list[dict]: """把含數學式的 prompt MD 字串轉成 prompt_runs(供 render_inline_runs 用)。""" import re as _re MATH_FRAG = _re.compile(r'(\\\(.+?\\\)|\\\[.+?\\\]|\$\$.+?\$\$|\$(?!\$).+?(? pos: push_text(md[pos:m.start()]) latex, is_block = _strip(m.group(0)) runs.append({"kind": "math_block" if is_block else "math", "latex": latex}) pos = m.end() if pos < len(md): push_text(md[pos:]) return runs def _parse_ai_md_to_quiz_nodes( md_text: str, default_qtype: str = "choice", ) -> list[dict]: """把 AI 生成的 Markdown 題目字串解析成 quiz node 清單。""" md_text = (md_text or "").replace("$", "$").replace("(", "(").replace(")", ")") nodes: list[dict] = [] blocks = [] for m in _RE_AI_CHOICE.finditer(md_text): blocks.append(("choice", m.start(), m.end(), m)) for m in _RE_AI_FILL.finditer(md_text): blocks.append(("fill", m.start(), m.end(), m)) blocks.sort(key=lambda x: x[1]) last_end = 0 for kind, s, e, m in blocks: stem = (md_text[last_end:s] or "").strip() stem = re.sub(r"^\s*題目\s*[::]\s*", "", stem) attrs = _attrs_to_dict_local(m.group("attrs") or "") body = (m.group("body") or "").strip() if kind == "choice": lines = [ln.rstrip() for ln in body.splitlines()] prompt_lines, opt_lines, hit_opt = [], [], False for ln in lines: if _OPT_LINE_RE.match(ln.strip()): hit_opt = True (opt_lines if hit_opt else prompt_lines).append(ln) prompt_md = ("\n".join(x for x in prompt_lines if x.strip()) or stem).strip().replace("$", "$") opts = [] for ln in opt_lines: mm = _OPT_LINE_RE.match(ln.strip()) if mm: opts.append({"key": mm.group(1).lower(), "text": mm.group(2).strip()}) ans = (attrs.get("ans") or "").strip().lower().rstrip(".") idxmap = {"1": "a", "2": "b", "3": "c", "4": "d"} if ans in idxmap: ans = idxmap[ans] nodes.append({ "type": "quiz", "qtype": "choice", "id": "", "prompt_md": prompt_md, "prompt_runs": _prompt_md_to_runs(prompt_md), "options": opts, "answer": ans, "card": "1", "ai": "1", }) else: ans_raw = attrs.get("ans") or "" answers = [a.strip() for a in ans_raw.split("|") if a.strip()] prompt_md = (body or stem).strip().replace("$", "$") nodes.append({ "type": "quiz", "qtype": "fill", "id": "", "prompt_md": prompt_md, "prompt_runs": _prompt_md_to_runs(prompt_md), "answers": answers, "normalize": DEFAULT_NORMALIZE, "card": "1", "ai": "1", }) last_end = e return nodes # --------------------------------------------------------------------------- # AI 批改回饋 # --------------------------------------------------------------------------- def _ai_feedback_multi_gemini( prompt_text: str, selected_keys: list[str], options: list[dict], answer_keys: list[str], rubric: str = "", model: str = "gemini-2.5-flash", lang: str = "zh-TW", ) -> dict: opt_map = {str(o.get("key")).lower(): o.get("text", "") for o in (options or [])} ask = ( "你是數學助教,請用繁體中文回覆。\n" "請判斷學生多選題是否正確,並提供具體引導。\n" "必要時可用行內 LaTeX($...$)。\n" '只輸出這段 JSON(不得多字):{"is_correct":true/false,"hint":"...","explain":"..."}\n\n' f"題目:{prompt_text}\n" f"選項:{json.dumps(opt_map, ensure_ascii=False)}\n" f"學生勾選:{json.dumps([str(k).lower() for k in (selected_keys or [])], ensure_ascii=False)}\n" f"正確答案:{json.dumps([str(k).lower() for k in (answer_keys or [])], ensure_ascii=False)}\n" + (f"評分規則:{rubric}\n" if rubric else "") ) obj = _first_json(_gem_call(ask, model=model)) or {} hint = _s(obj.get("hint")); explain = _s(obj.get("explain")) if not (hint or explain): return {} return {"hint": hint, "explain": explain} def _ai_feedback_choice_gemini( prompt_text: str, selected_key: str, options: list[dict], answer_key: str, rubric: str = "", model: str = "gemini-2.5-flash", lang: str = "zh-TW", ) -> str: opt_map = {str(o.get("key")).lower(): o.get("text", "") for o in (options or [])} ask = ( "你是數學助教,請用繁體中文回覆,提供引導式提示,不要直接公布正確答案。\n" "必要時可用行內 LaTeX($...$)。\n" '只輸出這段 JSON(不得多字):{"is_correct":true/false,"hint":"...","explain":"..."}\n\n' f"題目:{prompt_text}\n" f"選項:{json.dumps(opt_map, ensure_ascii=False)}\n" f"學生選的鍵:{selected_key}\n" f"學生選的內容:{opt_map.get(selected_key, '')}\n" f"正解鍵:{answer_key}\n" f"正解內容:{opt_map.get(answer_key, '')}\n" + (f"評分規則:{rubric}\n" if rubric else "") ) txt = _gem_call(ask, model=model) obj = _first_json(txt) print(ask) return _s((obj or {}).get("hint")) def _ai_feedback_fill_gemini( prompt_text: str, user_input: str, answers: list[str], rubric: str = "", model: str = "gemini-2.5-flash", lang: str = "zh-TW", ) -> dict: ask = ( "你是數學助教,請用繁體中文回覆。\n" "判斷學生填空題是否正確,提供具體診斷與引導修正。\n" "必要時可用行內 LaTeX($...$)。\n" '只輸出這段 JSON(不得多字):{"is_correct":true/false,"hint":"...","explain":"..."}\n\n' f"題目:{prompt_text}\n" f"學生作答:{user_input}\n" f"可接受答案:{json.dumps(answers, ensure_ascii=False)}\n" + (f"評分規則:{rubric}\n" if rubric else "") ) txt = _gem_call(ask, model=model) obj = _first_json(txt) or {} print(ask) return { "is_correct": bool((obj or {}).get("is_correct")), "hint": _s((obj or {}).get("hint")), "explain": _s((obj or {}).get("explain")), }