docs(evals): S01–S10 标注集 v1、评测工具和评测报告

- evals/gold:1,887 个标注单元(AI 标注、逐条裁定,未经人工复核),规范见 GUIDELINES.md
- prepare / run_all / score / gold:导出文字层、批量抽取、打分、标注定位
- docs/architecture/extract-eval.md:修复前后的结果(三项目标全部达标)和剩余问题
- decisions.md 记 M1(只用 Flash)和 Q1–Q4;架构 §13 SK13;AGENTS.md 进度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 23:19:30 +08:00
parent f98fd51128
commit 602703c47c
21 changed files with 29427 additions and 18 deletions

300
evals/score.py Normal file
View File

@@ -0,0 +1,300 @@
"""标注集评测:用 evals/gold/ 的标注给一次抽取(run_all.sh 的输出)打分。
用法(在 server/ 下):../.venv/bin/python ../evals/score.py <运行名> [S01 ...]
输出 evals/out/score/<运行名>/:summary.md(各类召回率、准确率、项目信息正确率)、<ID>.md(漏抽、多抽、
被误拦进待确认区的条目,给人工裁定用)、score.json。
口径:
- 一条标注被「召回」= 有一个对应类别的清单项,出处句子和标注的句子有交集。正式清单算一次,正式 + 待确认再算一次
(待确认区用户也看得到,只是要多点一下)。
- 「准确」= 正式清单里这一类的条目,出处和这一类的某条标注(含 optional)有交集。
- 技术参数只在抽样页上算:标注是抽样页上的全部要求,条目只算出处落在抽样页上的。
- optional 的标注不算进召回的分母,抽到了算对。
"""
from __future__ import annotations
import json
import pathlib
import re
import sys
from collections import defaultdict
from decimal import Decimal, InvalidOperation
from typing import Any
import gold
ROOT = pathlib.Path(__file__).resolve().parent
CAT = {
"substantive": "invalidity",
"qualification": "qualification",
"scoring": "scoring",
"schedule": "schedule",
"tech_param": "tech_param",
}
NAMES = {
"substantive": "实质性条款",
"substantive/marked": " 标注类(★ / * / 斜体下划线)",
"substantive/explicit_invalid": " 写明无效类",
"qualification": "资格条件",
"scoring": "评分项",
"schedule": "日期与递交",
"tech_param": "技术参数(抽样页)",
}
def subgroup(u: gold.Unit) -> str | None:
if u.cat != "substantive":
return None
return "substantive/explicit_invalid" if u.raw.get("basis") == "explicit_invalid" else "substantive/marked"
def pct(a: int, b: int) -> str:
return f"{a / b:.1%}({a}/{b})" if b else "—"
def text_of(doc_id: str, sids: list[str] | set[str], limit: int = 80) -> str:
by = {gold.sid(s): s for s in gold.sentences(doc_id)}
return " / ".join(by[k].text[:limit] for k in sorted(sids, key=_order) if k in by)
def _order(k: str) -> tuple[int, int]:
m = re.match(r"p(\d+)s(\d+)", k)
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
def score_doc(run: str, doc_id: str) -> tuple[dict[str, Any], list[str]]:
out_dir = ROOT / "out" / "runs" / run / doc_id
items = json.loads((out_dir / "items.json").read_text())
info = json.loads((out_dir / "info.json").read_text())
g = gold.load(doc_id)
units, problems = gold.units(doc_id)
pages = set(g.get("tech_param_pages") or [])
star_scope = star_sections(doc_id, g)
page_of = lambda k: _order(k)[0] # noqa: E731
res: dict[str, Any] = {"problems": problems}
detail: list[str] = [f"# {doc_id} · {run}", ""]
if problems:
detail += ["## 标注定位问题", *(f"- {p}" for p in problems), ""]
def in_scope(cat: str, it: dict[str, Any]) -> bool:
return cat != "tech_param" or any(page_of(k) in pages for k in it["sentences"])
qual_sids = set().union(*(u.sids for u in units if u.cat == "qualification"))
for cat, pcat in CAT.items():
cu = [u for u in units if u.cat == cat]
preds = [it for it in items if pcat in it["categories"] and in_scope(cat, it)]
formal = [it for it in preds if it["zone"] == "formal"]
# 待确认区:待确认的条目,以及正式条目上「待确认的类别」(extract.add_pending_categories)
pending = [it for it in preds if it["zone"] != "formal"] + [
it
for it in items
if it["zone"] == "formal"
and pcat not in it["categories"]
and pcat in ((it.get("raw") or {}).get("pending_categories") or {})
and in_scope(cat, it)
]
gold_sids = set().union(*(u.sids for u in cu)) if cu else set()
def hit(u: gold.Unit, pool: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [it for it in pool if u.sids & set(it["sentences"])]
groups: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
missed, pend_only, dups = [], [], 0
for u in cu:
if u.optional:
continue
f, p = hit(u, formal), hit(u, pending)
other = [it for it in items if u.sids & set(it["sentences"])]
for key in filter(None, (cat, subgroup(u))):
groups[key]["gold"] += 1
groups[key]["formal"] += bool(f)
groups[key]["any"] += bool(f or p)
groups[key]["anycat"] += bool(other)
dups += len(f) > 1
if not f and not p:
missed.append((u, other))
elif not f:
pend_only.append((u, p))
def ok(it: dict[str, Any]) -> bool:
if gold_sids & set(it["sentences"]):
return True
if cat != "substantive":
return False
if set(it["sentences"]) <= star_scope: # 口径 Q1:★ 节里的条目(节内没有逐条标注)
return True
# 口径 Q4:政府采购项目的资格条件按法规(87 号令第六十三条)带「无效」类别
return it.get("importance_basis") == "statute" and bool(qual_sids & set(it["sentences"]))
right = [it for it in formal if ok(it)]
wrong = [it for it in formal if not ok(it)]
good_pending = [it for it in pending if gold_sids & set(it["sentences"])]
for key in [cat] + ([k for k in groups if k.startswith(cat + "/")] if cat == "substantive" else []):
res[key] = dict(groups[key])
res[cat].update(
pred_formal=len(formal),
formal_right=len(right),
pending=len(pending),
pending_right=len(good_pending),
dups=dups,
)
detail.append(f"## {NAMES[cat]}")
if missed:
detail.append(f"### 漏抽({len(missed)})")
for u, other in missed:
where = (
";被别的类别抽到:" + ",".join(sorted({c for it in other for c in it["categories"]}))
if other
else ""
)
detail.append(
f"- **{u.id}** {u.raw.get('basis') or u.raw.get('kind') or ''} {u.raw.get('note', '')}{where}"
)
detail.append(f" - {text_of(doc_id, u.sids)}")
if pend_only:
detail.append(f"### 只在待确认区({len(pend_only)})")
for u, p in pend_only:
reasons = ",".join(
sorted(
{
r
for it in p
for r in (it.get("raw", {}) or {}).get("failures", []) or [it.get("reason") or ""]
}
)
)
detail.append(f"- **{u.id}** {u.raw.get('note', '')}〔{reasons}〕")
if wrong:
detail.append(f"### 正式清单里对不上标注的({len(wrong)})")
for it in wrong:
detail.append(f"- [{it['task']}] {it['title']}|{text_of(doc_id, it['sentences'], 60)}")
detail.append("")
res["info"] = score_info(g, info)
detail.append("## 项目信息")
for k, (ok, want, got) in res["info"].items():
detail.append(f"- {'✓' if ok else '✗'} {k}:标注 {want},抽到 {got}")
if g.get("marking", {}).get("declared_count"):
n = sum(1 for u in units if u.cat == "substantive" and u.raw.get("basis") == "star")
res["declared"] = [g["marking"]["declared_count"], n]
return res, detail
def star_sections(doc_id: str, g: dict[str, Any]) -> set[str]:
"""标注集里记的 ★ 节范围(marking.star_sections,from 含、to 不含)里的句子编号。"""
order = [gold.sid(s) for s in gold.sentences(doc_id)]
out: set[str] = set()
for sec in (g.get("marking") or {}).get("star_sections") or []:
a, _ = gold.locate(doc_id, sec["from"]["p"], sec["from"]["t"])
b, _ = gold.locate(doc_id, sec["to"]["p"], sec["to"]["t"])
if a and b:
out.update(order[order.index(a[0]) : order.index(b[0])])
return out
def yuan(v: Any, unit: Any) -> Decimal | None:
try:
n = Decimal(str(v).replace(",", "").replace(",", ""))
except (InvalidOperation, ValueError):
return None
return n * 10000 if unit and "万" in str(unit) else n
def score_info(g: dict[str, Any], info: dict[str, Any]) -> dict[str, tuple[bool | None, Any, Any]]:
"""每个字段:(对不对, 标注值, 抽到的值)。标注是 null 的,抽到「没写」才算对;抽到 needs_review 算没答出来。"""
want_all = g.get("project_info") or {}
out: dict[str, tuple[bool | None, Any, Any]] = {}
for k in gold.INFO_KEYS:
want = want_all.get(k)
f = info["fields"].get(k)
status = info["status"].get(k, {}).get("status")
got: Any = f["value"] if f else f"〔{status}〕"
if f is None:
ok = want is None and status == "absent"
elif want is None:
ok = False
elif k in ("budget_total", "ceiling_total"):
w = yuan(want.get("value"), want.get("unit")) if isinstance(want, dict) else yuan(want, None)
ok = w is not None and w == yuan(f["value"], f.get("unit"))
got = f"{f['value']}{f.get('unit') or ''}"
elif isinstance(want, bool):
ok = f["value"] is want
else:
a, b = gold.norm(str(want)), gold.norm(str(f["value"]))
ok = a == b if k in ("code", "legal_regime") else bool(a and b and (a in b or b in a))
if isinstance(want, dict):
want = f"{want.get('value')}{want.get('unit') or ''}"
out[k] = (ok, want, got)
return out
def main() -> None:
run = sys.argv[1]
ids = sys.argv[2:] or [p.stem for p in sorted(gold.GOLD.glob("S*.json"))]
dest = ROOT / "out" / "score" / run
dest.mkdir(parents=True, exist_ok=True)
all_res: dict[str, Any] = {}
for doc_id in ids:
if not (ROOT / "out" / "runs" / run / doc_id / "items.json").exists():
print(doc_id, "没有抽取结果,跳过")
continue
res, detail = score_doc(run, doc_id)
all_res[doc_id] = res
(dest / f"{doc_id}.md").write_text("\n".join(detail) + "\n")
(dest / "score.json").write_text(json.dumps(all_res, ensure_ascii=False, indent=1, default=str))
lines = [f"# 标注集评测 · {run}", "", f"文件:{', '.join(all_res)}", ""]
lines += [
"| 类别 | 标注 | 召回(正式清单) | 召回(含待确认) | 准确率(正式清单) | 待确认里是好结果 | 重复条目 |",
"|---|---|---|---|---|---|---|",
]
for key in NAMES:
tot: dict[str, int] = defaultdict(int)
for r in all_res.values():
for k, v in (r.get(key) or {}).items():
tot[k] += v
prec = pct(tot["formal_right"], tot["pred_formal"]) if "/" not in key else ""
pend = pct(tot["pending_right"], tot["pending"]) if "/" not in key else ""
dups = str(tot["dups"]) if "/" not in key else ""
lines.append(
f"| {NAMES[key]} | {tot['gold']} | {pct(tot['formal'], tot['gold'])} | {pct(tot['any'], tot['gold'])} | {prec} | {pend} | {dups} |"
)
lines += [
"",
"## 按文件",
"",
"| 文件 | " + " | ".join(NAMES[k].strip(" ") for k in NAMES) + " |",
"|---|" + "---|" * len(NAMES),
]
for doc_id, r in all_res.items():
cells = []
for key in NAMES:
v = r.get(key) or {}
cell = f"{v.get('formal', 0)}/{v.get('any', 0)}/{v.get('gold', 0)}"
if "/" not in key:
cell += f" · {v.get('formal_right', 0)}/{v.get('pred_formal', 0)}"
cells.append(cell)
lines.append(f"| {doc_id} | " + " | ".join(cells) + " |")
lines += ["", "格子里:正式召回/含待确认召回/标注数 · 正式条目对上标注/正式条目数", ""]
lines += ["## 项目信息", "", "| 字段 | 正确 | 错的 |", "|---|---|---|"]
for k in gold.INFO_KEYS:
oks = [(d, r["info"][k]) for d, r in all_res.items()]
bad = [f"{d}(标 {w},抽 {g_})" for d, (ok, w, g_) in oks if not ok]
lines.append(f"| {k} | {sum(1 for _, (ok, _, _) in oks if ok)}/{len(oks)} | {';'.join(bad)} |")
declared = {d: r["declared"] for d, r in all_res.items() if "declared" in r}
if declared:
lines += [
"",
"文件写明的 ★ 条款数 / 标注的 ★ 单元数:" + ",".join(f"{d} {a}/{b}" for d, (a, b) in declared.items()),
]
problems = {d: r["problems"] for d, r in all_res.items() if r["problems"]}
if problems:
lines += ["", "## 标注定位问题", *(f"- {d}:{len(p)} 处" for d, p in problems.items())]
(dest / "summary.md").write_text("\n".join(lines) + "\n")
print("\n".join(lines))
if __name__ == "__main__":
main()