Files
bid-doctor/evals/check/render.py
YANG JIANKUAN 4ed945c1de 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>
2026-11-04 12:02:40 +08:00

185 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""把投标文件稿(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)