docs(evals): 查标评测集规范 v0.2、留出集划分;渲染和离线查标脚本;新增待定事项 C12

- evals/check/GUIDELINES.md:请 Fable 审查 v0.1 后修改。响应方式按「文件 × 类别」记(逐条 / 默认接受 /
  证明材料(投标时、中标后)/ 文件要在 / 平台内应答),10 份招标文件逐份核对;规矩投标文件靠构造保证
  (表格脚本生成,LLM 只写散文和改写),对全部要求响应,问题只挂人工复核过的标注单元;注入分单点 /
  连续块 / 全局三种粒度、带不带标志词、两档密度;留出集注入词库和 C 风格说明开工时封存;评分补只检索
  基线、按单元去重、端到端召回。
- evals/check/split.json:开发集 S01 S02 S04 S05 S07 S08 S10,留出集 S03 S06 S09 + 真实国动投标文件。
- evals/check/render.py、run_check.py。
- m3-plan.md:新增待定事项 C12(每条要求怎么才算「响应了」)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 12:02:26 +08:00
parent e499de7486
commit 4ed945c1de
6 changed files with 588 additions and 1 deletions

184
evals/check/render.py Normal file
View File

@@ -0,0 +1,184 @@
"""把投标文件稿(spec.json)渲染成 PDF,并回填响应清单里的位置(evals/check/GUIDELINES.md §5、§6)。
cd server && ../.venv/bin/python ../evals/check/render.py <投标文件编号> [...] # 需要 Gotenberg(:3109)
- 每个分册一份 PDF:evals/out/check/<bid>/<分册编号>.pdf;
- `scan` 块单独成页,渲染后把那一页换成图片(没有文字层),用来检验扫描件的处理(C10);
- 页眉写 volume.header(默认是本项目名称;注入 P06 时改成别的项目名称);
- 渲染后用读标同一套切句找到每个响应块所在的页,写进 manifest.json 的 where(页码 + 片段)。
"""
from __future__ import annotations
import html
import io
import json
import pathlib
import sys
from typing import Any
import httpx
import pypdfium2 as pdfium
from bid_doctor.document.pipeline import text_layer
ROOT = pathlib.Path(__file__).resolve().parent
OUT = ROOT.parent / "out" / "check"
GOTENBERG = "http://127.0.0.1:3109/forms/chromium/convert/html"
SNIPPET = 24 # 回填位置时取块文字的前多少个字
CSS = """
@page { size: A4; margin: 22mm 18mm 20mm; }
body { font-family: "Noto Serif CJK SC", "Noto Sans CJK SC", serif; font-size: 11pt; line-height: 1.75; margin: 0; }
h1 { font-size: 17pt; text-align: center; margin: 0 0 12pt; }
h2 { font-size: 14pt; margin: 14pt 0 6pt; }
h3 { font-size: 12pt; margin: 10pt 0 4pt; }
p { margin: 0 0 6pt; text-align: justify; }
table { width: 100%; border-collapse: collapse; margin: 6pt 0 10pt; font-size: 10pt; }
th, td { border: 0.6pt solid #333; padding: 3pt 4pt; vertical-align: top; }
th { background: #f2f2f2; }
.page { page-break-before: always; }
.scan { page-break-before: always; page-break-after: always; padding: 24mm 10mm; border: 1pt solid #999; min-height: 150mm; }
.scan h3 { text-align: center; }
.sign { margin-top: 18pt; text-align: right; }
"""
def esc(s: Any) -> str:
return html.escape(str(s if s is not None else ""))
def block_html(b: dict[str, Any]) -> str:
t = b.get("type")
if t == "title":
return f"<h1>{esc(b['text'])}</h1>"
if t == "heading":
level = int(b.get("level", 2))
cls = ' class="page"' if b.get("new_page") else ""
return f"<h{level}{cls}>{esc(b['text'])}</h{level}>"
if t == "para":
return f"<p>{esc(b['text'])}</p>"
if t == "sign":
return "<div class='sign'>" + "".join(f"<p>{esc(x)}</p>" for x in b["lines"]) + "</div>"
if t == "table":
head = "".join(f"<th>{esc(h)}</th>" for h in b.get("header") or [])
rows = "".join("<tr>" + "".join(f"<td>{esc(c)}</td>" for c in r["cells"]) + "</tr>" for r in b["rows"])
title = f"<h3>{esc(b['title'])}</h3>" if b.get("title") else ""
return f"{title}<table><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table>"
if t == "scan":
lines = "".join(f"<p>{esc(x)}</p>" for x in b.get("lines") or [])
return f"<div class='scan'><h3>{esc(b['text'])}</h3>{lines}</div>"
raise ValueError(f"未知的块类型:{t}")
def volume_html(spec: dict[str, Any], vol: dict[str, Any]) -> tuple[str, str]:
body = "".join(block_html(b) for b in vol["blocks"])
header = esc(vol.get("header") or spec.get("project_name") or "")
# 页眉用 Gotenberg 的 header.html 传,每页都有;读标切句会把各页重复的行认成页眉页脚带,
# 查标的「别的项目名称」规则要专门扫这里(m3-plan §2.4)
return (
f"<!doctype html><html><head><meta charset='utf-8'><style>{CSS}</style></head><body>{body}</body></html>",
header,
)
def render_pdf(page_html: str, header: str) -> bytes:
head = (
"<html><head><style>body{font-family:'Noto Sans CJK SC',sans-serif;font-size:8pt;color:#555;"
"width:100%;margin:0 18mm;}</style></head><body>" + esc(header) + "</body></html>"
)
files = [
("files", ("index.html", page_html.encode(), "text/html")),
("files", ("header.html", head.encode(), "text/html")),
]
data = {"marginTop": "0.9", "marginBottom": "0.8", "marginLeft": "0.7", "marginRight": "0.7"}
r = httpx.post(GOTENBERG, files=files, data=data, timeout=120)
r.raise_for_status()
return r.content
def rasterize(pdf: bytes, pages: set[int]) -> bytes:
"""把指定页(从 1 起)换成图片页:没有文字层,模拟扫描件。"""
if not pages:
return pdf
src = pdfium.PdfDocument(pdf)
out = pdfium.PdfDocument.new()
for i in range(len(src)):
if i + 1 in pages:
img = src[i].render(scale=1.6).to_pil().convert("L")
buf = io.BytesIO()
img.save(buf, format="PDF", resolution=115)
out.import_pages(pdfium.PdfDocument(buf.getvalue()))
else:
out.import_pages(src, [i])
buf = io.BytesIO()
out.save(buf)
return buf.getvalue()
def page_texts(pdf: bytes) -> dict[int, str]:
pages = text_layer.extract("B", pdf)
return {p.page_no: text_layer.norm("".join(s.text for s in p.sentences)) for p in pages}
def block_text(b: dict[str, Any], row: dict[str, Any] | None = None) -> str:
if row is not None:
# 定位用投标人写的那一格(anchor 指定;默认第 3 格「投标文件中的内容」),不用照抄的要求原文
cells = [str(c) for c in row["cells"]]
at = row.get("anchor", 2 if len(cells) >= 3 else len(cells) - 1)
return cells[at]
if b.get("type") == "scan":
return str(b["text"])
return str(b.get("text") or " ".join(b.get("lines") or []))
def locate(texts: dict[int, str], snippet: str) -> int | None:
q = text_layer.norm(snippet)[:SNIPPET]
for p, t in texts.items():
if q and q in t:
return p
return None
def render(bid: str) -> None:
d = OUT / bid
spec = json.loads((d / "spec.json").read_text())
manifest_path = d / "manifest.json"
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else None
where: dict[str, list[dict[str, Any]]] = {}
problems: list[str] = []
for vol in spec["volumes"]:
page_html, header = volume_html(spec, vol)
pdf = render_pdf(page_html, header)
texts = page_texts(pdf)
scans = {p for b in vol["blocks"] if b.get("type") == "scan" and (p := locate(texts, b["text"]))}
located: list[tuple[list[str], str]] = []
for b in vol["blocks"]:
if b.get("type") == "table":
located += [(r.get("responds") or [], block_text(b, r)) for r in b["rows"]]
else:
located.append((b.get("responds") or [], block_text(b)))
for units, snippet in located:
if not units:
continue
page = locate(texts, snippet)
if page is None:
problems.append(f"{vol['id']}:找不到「{snippet[:SNIPPET]}」")
continue
for u in units:
where.setdefault(u, []).append(
{"volume": vol["id"], "p": page, "t": text_layer.norm(snippet)[:SNIPPET]}
)
(d / f"{vol['id']}.pdf").write_bytes(rasterize(pdf, scans))
print(f"{bid}/{vol['id']}.pdf {len(texts)} 页,扫描页 {sorted(scans)}")
if manifest is not None:
for uid, entry in manifest["units"].items():
entry["where"] = where.get(uid, [])
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
for p in problems:
print(" ! " + p)
if __name__ == "__main__":
for b in sys.argv[1:]:
render(b)