Mochi0622's picture
initial deploy
e66cfb4
Raw
History Blame Contribute Delete
17.6 kB
"""
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'<!--\s*choice(?P<attrs>[^>]*)-->(?P<body>.*?)<!--\s*choice_end\s*-->', re.S | re.I)
_RE_AI_FILL = re.compile(r'<!--\s*fill(?P<attrs>[^>]*)-->(?P<body>.*?)<!--\s*fill_end\s*-->', 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"
'<!--choice ans="c" card="0" placeholder="請輸入答案"-->\n'
"a. 選項\nb. 選項\nc. 選項\nd. 選項\n<!--choice_end-->\n\n"
"格式(填空):\n"
"題目(可含 $...$,允許多行)\n"
'<!--fill ans="答案1|答案2" card="0" placeholder="請輸入答案"-->\n'
"<!--fill_end-->\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'(\\\(.+?\\\)|\\\[.+?\\\]|\$\$.+?\$\$|\$(?!\$).+?(?<!\$)\$)', _re.S)
def _strip(s: str):
t = s.strip()
if t.startswith(r'\(') and t.endswith(r'\)'): return t[2:-2], False
if t.startswith(r'\[') and t.endswith(r'\]'): return t[2:-2], True
if t.startswith('$$') and t.endswith('$$'): return t[2:-2], True
if t.startswith('$') and t.endswith('$'): return t[1:-1], False
return t, False
md = md or ""
runs: list[dict] = []
def push_text(text: str):
for i, part in enumerate(text.split("\n")):
r: dict = {"kind": "text", "text": part}
if i != len(text.split("\n")) - 1:
r["newline"] = "true"
runs.append(r)
pos = 0
for m in MATH_FRAG.finditer(md):
if m.start() > 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")),
}