eval_one_sample.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. """单样本评估工具:sample.md = template + 一条帖子的字面产物,工具直接读字面调 LLM。
  2. sample.md 含 2 个 `=== BLOCK ===` 块(纯产物):
  3. === SYSTEM === system message 字面
  4. === USER === user message 字面(含填好的 query / rubric_md / rubric_json /
  5. 帖子 JSON / 输出要求)。多模态图片 URL 内嵌在帖子 JSON 的 post.images
  6. 字段——execute 时从 USER 字面提取那段 JSON,把 post.images 作为
  7. image_url 数组附给 LLM。
  8. 工作流:
  9. template + form_*.json 某条帖子 → `render` → 生成 sample.md → `execute` → LLM 输出评估 JSON
  10. 用法:
  11. # 1a. 智能查:-q + --title 自动在 runs_full/q{id}/ 下跨 form_A/B/C 按标题子串匹配,
  12. # 默认渲染到 eval_prompt_sample-mod.md
  13. python eval_one_sample.py render -q 1 --title "AI 抠图"
  14. python eval_one_sample.py render -q q0001 --title "提示词" # q-id 接受多种写法
  15. # 1b. 显式:传完整 form_*.json 路径 + case-id / index
  16. python eval_one_sample.py render --form runs_full/q0001/form_A.json --case-id xhs_abc123 --out my_sample.md
  17. python eval_one_sample.py render --form runs_full/q0001/form_A.json --index 0 --out my_sample.md
  18. # 2. 执行:读 USER 字面 + 从内嵌 JSON 提 post.images 作多模态附件 → LLM;
  19. # 结果落盘 <sample>.eval.json (含 model / cost / timestamp / 评估 JSON), 评估失败不写。
  20. python eval_one_sample.py execute my_sample.md --model qwen
  21. python eval_one_sample.py execute my_sample.md --model qwen --out my_result.json
  22. # 3. dump:打印 SYSTEM/USER 字面到 stdout(不调 LLM、不写文件)
  23. python eval_one_sample.py dump my_sample.md
  24. """
  25. import argparse
  26. import asyncio
  27. import json
  28. import re
  29. import sys
  30. from datetime import datetime
  31. from pathlib import Path
  32. from typing import Any, Dict, List, Optional, Tuple
  33. _PROJECT_ROOT = Path(__file__).resolve().parents[4] # search_eval/script/process_pipeline/examples/Agent 深度 4
  34. if str(_PROJECT_ROOT) not in sys.path:
  35. sys.path.insert(0, str(_PROJECT_ROOT))
  36. from examples.process_pipeline.script.llm_helper import call_llm_with_retry
  37. from examples.process_pipeline.script.llm_evaluate_sources import (
  38. _format_post_for_eval, _build_eval_messages,
  39. build_eval_llm_call, DEFAULT_EVAL_MODEL, EVAL_MODELS,
  40. )
  41. def _validate_minimal(data: Any) -> Optional[str]:
  42. """eval_one_sample 用的宽松校验:仅要求 LLM 输出是非空 JSON 对象。
  43. 跟 llm_evaluate_sources._validate_eval(写死了英文 schema 字段)不同 ——
  44. execute 是单样本调试工具,目的是看 LLM 在自定义 prompt 下的原始输出,不该耦合
  45. 任何特定字段名(支持中文 schema 的 mod.md 等)。严格 schema 校验留给批量评估管线。
  46. """
  47. if not isinstance(data, dict):
  48. return "输出不是 JSON 对象"
  49. if not data:
  50. return "输出是空对象"
  51. return None
  52. # ── sample.md 解析 ────────────────────────────────────────────────────────────
  53. # 块分隔符兼容两种格式:
  54. # - `=== BLOCK_NAME ===` 单独成行 (旧 sample.md / template.md 格式)
  55. # - `# BLOCK_NAME` markdown H1 标题 (mod.md 风格)
  56. # BLOCK_NAME 限定为大写英文/下划线 token, 行末无其他字符 —— 避免跟"# 中文注释"
  57. # 或"# query: ..."等含冒号/中文的行混淆。
  58. _BLOCK_HEADER_RE = re.compile(r"^(?:===\s+([A-Z_]+)\s+===|#\s+([A-Z_]+))\s*$")
  59. def parse_sample(path: Path) -> Dict[str, str]:
  60. """读 sample.md → 各块 raw 文本 dict。支持两种块分隔符,见 _BLOCK_HEADER_RE。"""
  61. text = path.read_text(encoding="utf-8")
  62. blocks: Dict[str, List[str]] = {}
  63. current: Optional[str] = None
  64. for line in text.splitlines():
  65. m = _BLOCK_HEADER_RE.match(line)
  66. if m:
  67. current = m.group(1) or m.group(2) # group(1)=== style; group(2)=# style
  68. blocks[current] = []
  69. continue
  70. if current:
  71. blocks[current].append(line)
  72. return {k: "\n".join(v).strip("\n") for k, v in blocks.items()}
  73. # markdown 编辑器有时会把 url 自动转成 `[url](url)`(如 mod.md 里 source_url / images);
  74. # 这层包裹要剥掉才能被 LLM 多模态 / requests 当作真 URL 用。
  75. _MD_LINK_RE = re.compile(r"^\s*\[(.+?)\]\(\s*(.+?)\s*\)\s*$")
  76. def _unwrap_md_link(s: str) -> str:
  77. """处理 markdown 链接污染: '[url](url)' → 'url'。原样返回非链接字符串。"""
  78. if not isinstance(s, str):
  79. return s
  80. m = _MD_LINK_RE.match(s)
  81. return m.group(2).strip() if m else s
  82. def _strip_comments(raw: str) -> str:
  83. """去掉以 `#` 开头的注释行(用于 POST/IMAGES 块解析)。"""
  84. return "\n".join(ln for ln in raw.splitlines() if not ln.lstrip().startswith("#")).strip()
  85. def parse_meta(raw: str) -> Dict[str, str]:
  86. """META 块: key: value 一行一条。"""
  87. meta: Dict[str, str] = {}
  88. for line in raw.splitlines():
  89. s = line.strip()
  90. if not s or s.startswith("#"):
  91. continue
  92. k, sep, v = s.partition(":")
  93. if sep:
  94. meta[k.strip()] = v.strip()
  95. return meta
  96. def parse_post(raw: str) -> Dict[str, Any]:
  97. """POST 块: 一段 JSON。"""
  98. text = _strip_comments(raw)
  99. if not text:
  100. raise ValueError("=== POST === 块为空")
  101. return json.loads(text)
  102. # markdown 编辑器(Typora/Obsidian 等)会自动在 `_` `*` `#` `[` `]` `(` `)` 这种字符前
  103. # 加反斜杠转义,防它们触发 markdown 语法(如 `_X_` 渲染斜体)。但 JSON 字符串里 `\X`
  104. # 只能是有限几种合法转义(`\n` `\t` `\"` `\\` 等),其他会让 json.loads 报错。
  105. # 这个正则只剥上面那几个字符前的反斜杠——它们都不可能是 JSON 合法转义目标,剥了零风险。
  106. _MD_AUTOESC_RE = re.compile(r"\\(?=[_*#\[\]()])")
  107. def extract_post_json_from_user(user_text: str) -> Optional[Dict[str, Any]]:
  108. """从 USER 块字面里提取嵌入的帖子 JSON(找『【待评估帖子』标记后的第一个完整 {...})。
  109. 用 brace counter 而不是正则——能正确处理嵌套对象 / 字符串里的 `{` `}`。
  110. json.loads 失败时尝试剥 markdown 编辑器自动转义(`\\_` 等)后 retry——sample.md 的常见坑。
  111. """
  112. marker_idx = user_text.find("【待评估帖子")
  113. if marker_idx < 0:
  114. return None
  115. start = user_text.find("{", marker_idx)
  116. if start < 0:
  117. return None
  118. depth = 0
  119. in_str = False
  120. escape = False
  121. for i in range(start, len(user_text)):
  122. c = user_text[i]
  123. if escape:
  124. escape = False
  125. continue
  126. if c == "\\":
  127. escape = True
  128. continue
  129. if c == '"':
  130. in_str = not in_str
  131. continue
  132. if in_str:
  133. continue
  134. if c == "{":
  135. depth += 1
  136. elif c == "}":
  137. depth -= 1
  138. if depth == 0:
  139. raw = user_text[start : i + 1]
  140. try:
  141. return json.loads(raw)
  142. except json.JSONDecodeError:
  143. # 试剥 markdown 自动转义后 retry
  144. try:
  145. return json.loads(_MD_AUTOESC_RE.sub("", raw))
  146. except json.JSONDecodeError:
  147. return None
  148. return None
  149. # ── render:form_*.json + template → sample.md(纯产物三块) ──────────────────
  150. def _pick_case(form_path: Path, case_id: Optional[str], index: int) -> Tuple[Dict[str, Any], Dict[str, Any], str]:
  151. """从 form_*.json 选一条 source。返回 (form_data, source, case_label)。"""
  152. form_data = json.loads(form_path.read_text(encoding="utf-8"))
  153. results = form_data.get("results", [])
  154. if not results:
  155. sys.exit(f"❌ {form_path} 的 results 为空")
  156. if case_id:
  157. source = next((r for r in results if r.get("case_id") == case_id), None)
  158. if source is None:
  159. sys.exit(f"❌ {form_path} 没找到 case_id={case_id!r};前 5 个: "
  160. f"{[r.get('case_id') for r in results[:5]]}")
  161. return form_data, source, case_id
  162. if index < 0 or index >= len(results):
  163. sys.exit(f"❌ {form_path} 共 {len(results)} 条,--index {index} 越界")
  164. source = results[index]
  165. return form_data, source, source.get("case_id", f"index_{index}")
  166. # ── -q + --title 智能查找 ───────────────────────────────────────────────────────
  167. _RUNS_FULL = Path(__file__).resolve().parent / "runs_full"
  168. def _normalize_q_id(s: str) -> int:
  169. """0001 / q0001 / q1 / 1 都接受,回返整数。"""
  170. import re
  171. m = re.search(r"\d+", s or "")
  172. if not m:
  173. sys.exit(f"❌ -q 参数不含数字: {s!r}")
  174. return int(m.group())
  175. def _find_in_q(q_id: int, title_sub: str, index: int
  176. ) -> Tuple[Path, Dict[str, Any], Dict[str, Any], str]:
  177. """在 runs_full/q{q_id:04d}/ 下找 form_A/B/C.json,按 title 子串选 post。
  178. title_sub 给了 → 跨三 form 大小写不敏感 substring 匹配;多匹配取第一个、其余列出。
  179. title_sub 为空 → 取 form_A 的 --index 那条(跟旧行为兼容)。
  180. 返回 (form_path, form_data, source, case_label)。
  181. """
  182. q_dir = _RUNS_FULL / f"q{q_id:04d}"
  183. if not q_dir.is_dir():
  184. sys.exit(f"❌ {q_dir} 不存在")
  185. forms = sorted(q_dir.glob("form_*.json"))
  186. if not forms:
  187. sys.exit(f"❌ {q_dir} 下无 form_*.json")
  188. if title_sub:
  189. sub = title_sub.lower()
  190. hits: List[Tuple[Path, Dict[str, Any], Dict[str, Any]]] = []
  191. for fp in forms:
  192. data = json.loads(fp.read_text(encoding="utf-8"))
  193. for r in data.get("results", []):
  194. title = (r.get("post") or {}).get("title", "") or ""
  195. if sub in title.lower():
  196. hits.append((fp, data, r))
  197. if not hits:
  198. sys.exit(f"❌ q{q_id:04d} 下无 title 含 {title_sub!r} 的帖子(扫了 {len(forms)} 个 form)")
  199. if len(hits) > 1:
  200. print(f"⚠️ {len(hits)} 条匹配,用第 1 条;其他候选:")
  201. for fp, _, r in hits[1:6]:
  202. t = ((r.get("post") or {}).get("title") or "(无标题)")[:60]
  203. print(f" {fp.name} {r.get('case_id','?'):<28} {t}")
  204. fp, data, source = hits[0]
  205. return fp, data, source, source.get("case_id", "matched")
  206. # 兜底:form_A 的 --index
  207. fp = forms[0]
  208. data = json.loads(fp.read_text(encoding="utf-8"))
  209. results = data.get("results", [])
  210. if index < 0 or index >= len(results):
  211. sys.exit(f"❌ {fp.name} 共 {len(results)} 条,--index {index} 越界")
  212. return fp, data, results[index], results[index].get("case_id", f"index_{index}")
  213. def write_rendered_sample(out_path: Path, src_label: str, query: str,
  214. system: str, user_text: str) -> None:
  215. """把 SYSTEM / USER 两块写到 sample.md,顶部加追溯注释。
  216. 不再单独写 IMAGES 块——多模态图片 URL 已在 USER 字面里(post.images 数组),
  217. execute 时直接从那里提取,避免一份数据存两处。
  218. """
  219. header = (
  220. f"# 已渲染的评估 prompt sample(template + 一条帖子 → 字面产物)\n"
  221. f"# 源: {src_label}\n"
  222. f"# query: {query}\n"
  223. f"# 工具: python eval_one_sample.py execute {out_path.name}\n"
  224. f"#\n"
  225. f"# 多模态图片 URL 内嵌在 USER 块的帖子 JSON 的 post.images 字段;execute 自动提取。\n"
  226. f"# 改 template / rubric / 换帖子后,重新跑 render 生成新 sample。\n"
  227. )
  228. parts = [
  229. header,
  230. "=== SYSTEM ===",
  231. system,
  232. "",
  233. "=== USER ===",
  234. user_text,
  235. "",
  236. ]
  237. out_path.write_text("\n".join(parts), encoding="utf-8")
  238. def _swap_in_place(existing_md: str, new_post_json_str: str, new_query: str) -> str:
  239. """在已有 sample.md 文本里只换帖子 JSON 和 【检索词】 反引号内容,其他段一字不动。
  240. 帖子 JSON: 找 "【待评估帖子" marker → 之后第一个完整 "{...}" (用 brace counter 处理嵌套)
  241. → 用 new_post_json_str 整段替换。**调用方负责把 source 走一次
  242. _format_post_for_eval 剥掉 llm_evaluation / 内部字段**,否则旧评估会
  243. 作为 prompt 输入污染新评估。
  244. 检索词: 找 "【检索词" marker → 之后第一个反引号串 → 替换里头内容。
  245. SYSTEM 块、USER 其他散文、注释、表格 全保留 —— 这是 in-place 模式跟 from-template 重生的核心区别。
  246. """
  247. marker = "【待评估帖子"
  248. mi = existing_md.find(marker)
  249. if mi < 0:
  250. raise ValueError(f"sample.md 缺 {marker!r} marker,无法 in-place 替换;先跑普通 render 生成基础结构")
  251. start = existing_md.find("{", mi)
  252. if start < 0:
  253. raise ValueError(f"{marker!r} 后没找到 JSON 起始 '{{'")
  254. depth = 0; in_str = False; escape = False; end = -1
  255. for i in range(start, len(existing_md)):
  256. c = existing_md[i]
  257. if escape: escape = False; continue
  258. if c == "\\": escape = True; continue
  259. if c == '"': in_str = not in_str; continue
  260. if in_str: continue
  261. if c == "{": depth += 1
  262. elif c == "}":
  263. depth -= 1
  264. if depth == 0:
  265. end = i + 1; break
  266. if end < 0:
  267. raise ValueError(f"{marker!r} 后 JSON 大括号未闭合")
  268. out = existing_md[:start] + new_post_json_str + existing_md[end:]
  269. # 检索词替换:找 【检索词 后第一行的反引号包裹串
  270. qmi = out.find("【检索词")
  271. if qmi >= 0:
  272. bt = re.search(r"`[^`\n]*`", out[qmi:])
  273. if bt:
  274. s, e = qmi + bt.start(), qmi + bt.end()
  275. out = out[:s] + f"`{new_query}`" + out[e:]
  276. return out
  277. def cmd_render(args: argparse.Namespace) -> None:
  278. # 3 种入口:-q (+--title)、--form、二者必给一
  279. if args.q_id:
  280. q_id = _normalize_q_id(args.q_id)
  281. form_path, form_data, source, case_label = _find_in_q(q_id, args.title, args.index)
  282. elif args.form:
  283. form_path = args.form
  284. form_data, source, case_label = _pick_case(args.form, args.case_id, args.index)
  285. else:
  286. sys.exit("❌ 必须提供 -q <q_id> 或 --form <path>")
  287. query = args.query or form_data.get("query", "")
  288. requirement = args.requirement or ""
  289. n_images = len((source.get("post") or {}).get("images") or [])
  290. src_label = f"{form_path.parent.name}/{form_path.name}#{case_label}"
  291. # ── in-place 模式:读现有 sample.md, 只换帖子 JSON + 检索词, 保留所有手改 prompt/字段
  292. if args.in_place:
  293. if not args.out.exists():
  294. sys.exit(f"❌ --in-place 需要 {args.out} 已存在;先跑一次普通 render 生成基础结构")
  295. existing = args.out.read_text(encoding="utf-8")
  296. # 走一次 _format_post_for_eval 把 llm_evaluation / images_sent / 内部 _ 字段剥掉,
  297. # 不然旧评估当 prompt 输入会污染新评估
  298. post_json_str = _format_post_for_eval(source)
  299. new_md = _swap_in_place(existing, post_json_str, query)
  300. args.out.write_text(new_md, encoding="utf-8")
  301. print(f"✓ render (in-place) → {args.out}")
  302. print(f" 源: {src_label}")
  303. print(f" query: {query!r}")
  304. print(f" images {n_images} 张(内嵌在 post.images,execute 时自动取)")
  305. return
  306. # ── 默认:从 template 全重生
  307. post_block = _format_post_for_eval(source)
  308. # 不传 image_urls —— USER 文本里不拼 IMAGE_HINT;execute 自己从 post.images 取图时再追加 hint
  309. # rubric 已固化进 eval_prompt_template.md, 不再 load 外部 rubric 文件
  310. messages = _build_eval_messages(
  311. requirement=requirement, post_block=post_block,
  312. image_urls=None, query=query,
  313. )
  314. system = messages[0]["content"]
  315. user_text = messages[1]["content"]
  316. if isinstance(user_text, list):
  317. user_text = next(b["text"] for b in user_text if b["type"] == "text")
  318. write_rendered_sample(args.out, src_label, query, system, user_text)
  319. print(f"✓ render → {args.out}")
  320. print(f" 源: {src_label}")
  321. print(f" query: {query!r}")
  322. print(f" system {len(system)} chars / user {len(user_text)} chars / "
  323. f"images {n_images} 张(内嵌在 post.images,execute 时自动取)")
  324. # ── execute:读 SYSTEM/USER/IMAGES 拼 messages → LLM ──────────────────────────
  325. def build_messages_from_blocks(blocks: Dict[str, str], include_images: bool,
  326. max_images: int = 10
  327. ) -> Tuple[List[Dict[str, Any]], int]:
  328. """从 sample.md 拼 messages: SYSTEM/USER 块字面 + 从 USER 内嵌 JSON 取 post.images。
  329. 多模态时:
  330. 1) 从 USER 块字面提取嵌入的帖子 JSON(extract_post_json_from_user);
  331. 2) 取 post.images URL 列表(截到 max_images 防 token 爆);
  332. 3) user content 末尾追加 USER_IMAGE_HINT 提示 LLM 下方有图;
  333. 4) 拼 image_url 数组挂在 user content list 后。
  334. """
  335. from examples.process_pipeline.script.llm_evaluate_sources import load_prompt_template
  336. system = blocks.get("SYSTEM", "").strip()
  337. user_text = blocks.get("USER", "").strip()
  338. if not system or not user_text:
  339. raise ValueError("sample.md 缺 SYSTEM / USER 块——先跑 `render` 生成")
  340. image_urls: List[str] = []
  341. if include_images:
  342. post = extract_post_json_from_user(user_text)
  343. if post:
  344. raw_urls = (post.get("post") or {}).get("images") or []
  345. for u in raw_urls:
  346. if not isinstance(u, str):
  347. continue
  348. u = _unwrap_md_link(u) # mod.md 的 markdown 链接污染清洗
  349. if u.startswith("http"):
  350. image_urls.append(u)
  351. image_urls = image_urls[:max_images]
  352. if image_urls:
  353. hint = load_prompt_template().get("USER_IMAGE_HINT", "")
  354. text = user_text + ("\n\n" + hint if hint else "")
  355. user_content: List[Dict[str, Any]] = [{"type": "text", "text": text}]
  356. for u in image_urls:
  357. user_content.append({"type": "image_url", "image_url": {"url": u}})
  358. msgs = [{"role": "system", "content": system},
  359. {"role": "user", "content": user_content}]
  360. else:
  361. msgs = [{"role": "system", "content": system},
  362. {"role": "user", "content": user_text}]
  363. return msgs, len(image_urls)
  364. async def cmd_execute(args: argparse.Namespace) -> None:
  365. from dotenv import load_dotenv
  366. load_dotenv()
  367. blocks = parse_sample(args.sample)
  368. messages, n_images = build_messages_from_blocks(
  369. blocks, include_images=not args.no_images, max_images=args.max_images,
  370. )
  371. llm_call, model_id = build_eval_llm_call(args.model)
  372. print(f"=== 执行 {args.sample.name} | 模型: {model_id} | 图片: {n_images} 张 ===\n")
  373. data, cost = await call_llm_with_retry(
  374. llm_call=llm_call, messages=messages, model=model_id,
  375. temperature=0.1, max_tokens=2000,
  376. validate_fn=_validate_minimal, task_name=f"OneSample[{args.sample.stem}]",
  377. )
  378. print(f"\n--- 评估结果(cost ${cost:.4f})---")
  379. if data is None:
  380. print("❌ 评估失败(校验未通过或重试耗尽,见上方日志);不写文件")
  381. return
  382. print(json.dumps(data, ensure_ascii=False, indent=2))
  383. # 落盘:默认 <sample>.eval.json (同目录),含元数据 + 评估 JSON; 评估失败不写,避免覆盖好结果
  384. out_path = args.out or (args.sample.parent / f"{args.sample.stem}.eval.json")
  385. payload = {
  386. "sample": args.sample.name,
  387. "model": model_id,
  388. "image_count": n_images,
  389. "cost": round(cost, 4),
  390. "timestamp": datetime.now().isoformat(timespec="seconds"),
  391. "evaluation": data,
  392. }
  393. out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  394. print(f"\n💾 evaluation 落盘 → {out_path}")
  395. # ── 默认 dump:打印 SYSTEM/USER 字面 ──────────────────────────────────────────
  396. def cmd_dump(args: argparse.Namespace) -> None:
  397. blocks = parse_sample(args.sample)
  398. system = blocks.get("SYSTEM", "").strip()
  399. user_text = blocks.get("USER", "").strip()
  400. if not system or not user_text:
  401. sys.exit("❌ sample.md 缺 SYSTEM/USER 块——先跑 `render` 生成")
  402. # 从 USER 字面提帖子 JSON, 显示 post.images 数量(execute 会取这些图作多模态)
  403. post = extract_post_json_from_user(user_text)
  404. n_images = len((post.get("post") or {}).get("images") or []) if post else 0
  405. print(f"=== SYSTEM ({len(system)} chars) ===\n")
  406. print(system)
  407. print(f"\n\n=== USER ({len(user_text)} chars) ===\n")
  408. print(user_text)
  409. if n_images:
  410. print(f"\n\n[内嵌帖子 JSON 含 {n_images} 张图;execute 时将作多模态附件取用]")
  411. # ── CLI ───────────────────────────────────────────────────────────────────────
  412. def main() -> None:
  413. sys.stdout.reconfigure(encoding="utf-8")
  414. parser = argparse.ArgumentParser(description="单样本评估:render / execute / dump")
  415. sub = parser.add_subparsers(dest="cmd", required=True)
  416. _DEFAULT_OUT = Path(__file__).resolve().parent / "eval_prompt_sample-mod.md"
  417. p_render = sub.add_parser("render", help="从 form_*.json 取一条帖子 → 当前 template 渲染 → 写出 sample.md")
  418. p_render.add_argument("-q", "--q-id", default=None,
  419. help="runs_full 下的 q 编号(接受 0001 / q0001 / q1 / 1);跟 --form 二选一")
  420. p_render.add_argument("--title", default="",
  421. help="按帖子 title 子串匹配(不区分大小写;跨 form_A/B/C 一起搜,多匹配取第一条)")
  422. p_render.add_argument("--form", type=Path, default=None,
  423. help="form_*.json 完整路径;跟 -q 二选一")
  424. p_render.add_argument("--case-id", default=None, help="按 case_id 选 case(优先于 --index)")
  425. p_render.add_argument("--index", type=int, default=0,
  426. help="按下标选 case(默认 0;--case-id / --title 命中时忽略)")
  427. p_render.add_argument("--out", type=Path, default=_DEFAULT_OUT,
  428. help=f"输出 sample.md 路径(默认 {_DEFAULT_OUT.name})")
  429. p_render.add_argument("--query", default="", help="覆盖 form.query")
  430. p_render.add_argument("--requirement", default="", help="评估时的 requirement(默认空)")
  431. p_render.add_argument("--in-place", action="store_true",
  432. help="原地替换 --out 已有 sample.md 的帖子 JSON 和 检索词,保留手改的 prompt/字段;"
  433. "不开则按 eval_prompt_template.md 全重生(会覆盖手改)")
  434. p_exec = sub.add_parser("execute", help="读 sample.md 字面调 LLM 评估")
  435. p_exec.add_argument("sample", type=Path)
  436. p_exec.add_argument("--model", default=DEFAULT_EVAL_MODEL,
  437. help=("shortcut: " + ", ".join(EVAL_MODELS) +
  438. "; 也可直接传 raw 模型 id (如 google/gemini-3.1-flash-lite / openai/gpt-5.4)"))
  439. p_exec.add_argument("--no-images", action="store_true", help="不发图(纯文本评估)")
  440. p_exec.add_argument("--max-images", type=int, default=10,
  441. help="多模态最多发几张图(默认 10;防 token 爆)")
  442. p_exec.add_argument("--out", type=Path, default=None,
  443. help="评估结果 JSON 输出路径(默认 <sample>.eval.json,跟 sample 同目录;评估失败不写)")
  444. p_dump = sub.add_parser("dump", help="打印 SYSTEM/USER 字面(不调 LLM、不写文件)")
  445. p_dump.add_argument("sample", type=Path)
  446. args = parser.parse_args()
  447. if args.cmd == "render":
  448. cmd_render(args)
  449. elif args.cmd == "execute":
  450. asyncio.run(cmd_execute(args))
  451. else: # dump
  452. cmd_dump(args)
  453. if __name__ == "__main__":
  454. main()