Files
bid-doctor/evals/check/render.py
YANG JIANKUAN a07663cbdb feat(evals): 查标评测集生成器、注入、评分和只检索基线
- build.py:从「招标文件档案」和读标清单生成规矩投标文件稿;技术参数按 op / value / unit 算满足的值,
  分项报价按分项上限比例分摊,落款日期取截止日前 3 天,偏离表按格式表头决定列(含设备名称列),
  读标漏抽的技术参数也补上响应;routes 支持「+」组合和 prose 散文响应。
- mutate.py:按词库注入要求项问题(单点 / 连续块,带不带标志词)和规则问题,写响应清单;
  符合性条件响应表的问题按表级记一个。
- score.py:按单元去重、类型族比对、读标漏抽单列、端到端召回、无法对照单列。
- run_check.py:加只检索不调模型的基线;render.py:扫描件按最后一次出现定位。
- vocab-dev.json:开发集注入词库(留出集的另行封存,不进 git)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:18:48 +08:00

184 lines
7.6 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, last: bool = False) -> int | None:
"""last:取最后一次出现的页(附件在文件末尾,名称常和前面格式文件的标题相同)。"""
q = text_layer.norm(snippet)[:SNIPPET]
hits = [p for p, t in texts.items() if q and q in t]
return (hits[-1] if last else hits[0]) if hits else 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"], last=True))}
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)