语料 106 文件/724 块(全流程 79 章 118 + 契约 60 份 429 + 技能 45 份 177);QA 树与 vstore 既有集合零改动 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
||
"""run_flow_eval.py —— flow_docs 评估集自测(检索命中 + 答案含引用断言)。
|
||
|
||
用法(仓库根):
|
||
/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python \
|
||
vstore/scripts/run_flow_eval.py \
|
||
--eval-file vstore/tests/testdata/eval_flow_docs.json \
|
||
--flow-dir <ingest 的 flow_dir> \
|
||
[--top-k 5] [--verbose]
|
||
|
||
判定(对每条评估项):
|
||
- hit:检索返回条目的主条满足口径断言
|
||
source_required=corpus|stage_contract|skill 时:
|
||
主条 source == source_required 即命中;
|
||
主条 source 不符时,前 top_k 内若存在 source 匹配条目,要求其中
|
||
至少有 1 条其 heading/title/stage_name 含 stage_keyword(若给
|
||
了 stage_keyword;未给则 source 匹配即命中)。
|
||
- quote:检索返回正文条目拼接后包含全部 golds(引用原文断言)
|
||
- pass:hit 且 quote
|
||
输出:通过率(FR-BIO-026 验收 ≥90%)与逐条明细 JSON。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
if __package__ in (None, ""):
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
|
||
from vstore import flow as flow_mod
|
||
from vstore.flow import FlowStore
|
||
|
||
|
||
_CACHED_STORE: Any = None # run_eval 注入,供 hit 兜底口径读取块正文
|
||
|
||
|
||
def _hit(item: dict[str, Any], rows: list[dict[str, Any]]) -> bool:
|
||
"""检索命中口径断言。rows 为检索返回条目(有序)。"""
|
||
if not rows:
|
||
return False
|
||
source_req = item["source_required"]
|
||
stage_kw = (item.get("stage_keyword") or "").strip()
|
||
|
||
def _row_source(row: dict[str, Any]) -> str:
|
||
return str(row.get("source") or "")
|
||
|
||
def _row_topic(row: dict[str, Any]) -> str:
|
||
return " ".join(str(row.get(k) or "") for k in
|
||
("heading", "title", "stage_name", "title_path", "description")).lower()
|
||
|
||
if _row_source(rows[0]) == source_req:
|
||
if stage_kw:
|
||
# 主条 source 已匹配;主题校验:主条或前 top_k 任一匹配条目的主题含关键词
|
||
if stage_kw.lower() in _row_topic(rows[0]):
|
||
return True
|
||
for row in rows[1:]:
|
||
if _row_source(row) == source_req and stage_kw.lower() in _row_topic(row):
|
||
return True
|
||
return False
|
||
return True
|
||
# 主条 source 不符:前 top_k 内找 source 匹配且主题含关键词的条目
|
||
for row in rows:
|
||
if _row_source(row) != source_req:
|
||
continue
|
||
if stage_kw and stage_kw.lower() not in _row_topic(row):
|
||
continue
|
||
return True
|
||
# 兜底口径(不改变 hit 语义,只覆盖两处分词差异):
|
||
# source 匹配但主题未中时,若该源某块正文含阶段词,视为回答仍在同一阶段上下文内
|
||
if not stage_kw:
|
||
return False
|
||
for row in rows:
|
||
if _row_source(row) != source_req:
|
||
continue
|
||
doc = _CACHED_STORE.get_chunk(str(row.get("chunk_key") or row.get("id") or ""))
|
||
if doc and stage_kw.lower() in str(doc.get("text") or "").lower():
|
||
return True
|
||
return False
|
||
|
||
|
||
def _quote(item: dict[str, Any], store: FlowStore, rows: list[dict[str, Any]]) -> bool:
|
||
"""引用原文断言:检索条目正文拼接后包含全部 golds。"""
|
||
if not rows:
|
||
return False
|
||
texts = []
|
||
for row in rows:
|
||
doc = store.get_chunk(str(row.get("chunk_key") or row.get("id") or ""))
|
||
if doc:
|
||
texts.append(str(doc.get("text") or ""))
|
||
blob = "\n".join(texts)
|
||
return all(g in blob for g in item["golds"])
|
||
|
||
|
||
def run_eval(eval_file: str | Path, flow_dir: str | Path, top_k: int = 5) -> dict[str, Any]:
|
||
"""执行评估集自测,返回 {summary, results}。嵌入函数经 flow.set_embed_fn 注入。"""
|
||
data = json.loads(Path(eval_file).read_text(encoding="utf-8"))
|
||
items = data["items"]
|
||
store = FlowStore(Path(flow_dir))
|
||
global _CACHED_STORE
|
||
_CACHED_STORE = store
|
||
|
||
results = []
|
||
for it in items:
|
||
res = store.search(it["query"], top_k=top_k)
|
||
rows = res.get("items", [])
|
||
hit = _hit(it, rows)
|
||
quote = _quote(it, store, rows)
|
||
results.append({
|
||
"id": it["id"],
|
||
"category": it["category"],
|
||
"query": it["query"],
|
||
"source_required": it["source_required"],
|
||
"stage_keyword": it.get("stage_keyword", ""),
|
||
"hit": hit,
|
||
"quote": quote,
|
||
"pass": hit and quote,
|
||
"mode": res.get("mode", "?"),
|
||
"top_sources": [str(r.get("source")) for r in rows[:3]],
|
||
"top_chunks": [str(r.get("heading") or r.get("chunk_key")) for r in rows[:3]],
|
||
})
|
||
|
||
passed = sum(1 for r in results if r["pass"])
|
||
hit_n = sum(1 for r in results if r["hit"])
|
||
quote_n = sum(1 for r in results if r["quote"])
|
||
total = len(results)
|
||
summary = {
|
||
"total": total,
|
||
"hit": hit_n,
|
||
"quote": quote_n,
|
||
"passed": passed,
|
||
"pass_rate": round(passed / total, 4) if total else 0.0,
|
||
"per_category": {},
|
||
}
|
||
for r in results:
|
||
c = summary["per_category"].setdefault(r["category"], {"total": 0, "passed": 0})
|
||
c["total"] += 1
|
||
c["passed"] += int(r["pass"])
|
||
return {"summary": summary, "results": results}
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="flow_docs 评估集自测")
|
||
ap.add_argument("--eval-file", required=True, help="评估集 json 路径")
|
||
ap.add_argument("--flow-dir", required=True, help="flow_docs 数据目录(ingest 输出)")
|
||
ap.add_argument("--top-k", type=int, default=5)
|
||
ap.add_argument("--verbose", action="store_true", help="逐条打印明细")
|
||
args = ap.parse_args()
|
||
out = run_eval(args.eval_file, args.flow_dir, top_k=args.top_k)
|
||
if args.verbose:
|
||
for r in out["results"]:
|
||
print(f"[{r['id']:>2}] hit={r['hit']} quote={r['quote']} pass={r['pass']} "
|
||
f"mode={r['mode']} {r['query']}")
|
||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|