feat(check): 需人工核对(P2)和分项 / 单价上限(C7)

- 需人工核对:份数、密封、递交、CA 签章加密、解密、保证金这类文件里看不出来的要求,
  从正式清单按主题合并、带招标原文出处;manual-checks 接口按项目存勾选、写勾选人和时间,
  复查沿用;查标页加「需人工核对」一块,覆盖范围写明列了几条
- 「报价」类清单项:读标从采购需求的清单表规则读出分项 / 单价上限(checklist/price.py);
  查标交给规则 PRICE.OVER_CEILING_ITEM 比对分项报价表,不交给模型;规则集 2026.11-r2
- 版面工具(分页、分行、归栏)挪到 document/pipeline/layout.py,读标和查标共用
- 评测:price_defects.py 从规矩投标文件派生只带报价问题的 4 份(P01、P02、P03)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 17:31:32 +08:00
parent db1b1f6f9f
commit 81c58798d2
22 changed files with 888 additions and 84 deletions

View File

@@ -0,0 +1,118 @@
"""从规矩的投标文件派生只带报价问题的投标文件(P01 超总价限价、P02 超采购包限价、P03 分项超单价上限)。
cd server && ../.venv/bin/python ../evals/check/price_defects.py <规矩的投标文件> <输出> <P01|P02|P03> [--variant ...]
然后 ../.venv/bin/python ../evals/check/render.py <输出>
- 只改一个分项的单价,再重算该行合价、合计、开标一览表的小写和大写,算术前后一致:只触发要测的那一条规则;
- 响应清单照抄规矩的那份(单元都是 expect=ok),rule_defects 记这一个问题;不挂标注单元,不需要复核(E1 只管单元);
- 限价、单价上限取招标档案(evals/out/check/tenders/<招标文件>.json),和读标抽出的值一致时才有意义,生成后自己核对一遍。
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
from decimal import ROUND_UP, Decimal
from typing import Any
ROOT = pathlib.Path(__file__).resolve().parent
OUT = ROOT.parent / "out" / "check"
sys.path.insert(0, str(ROOT))
from build import rmb_upper # noqa: E402
VARIANTS = {"P01": ["超 1 元", "超 1%"], "P02": ["超 1 元", "超 1%"], "P03": ["单价超 1%"]}
def fmt(v: Decimal) -> str:
return f"{v:,.2f}"
def blocks(spec: dict[str, Any]) -> list[dict[str, Any]]:
return [b for v in spec["volumes"] for b in v["blocks"]]
def reprice(spec: dict[str, Any], index: int, unit_price: Decimal) -> Decimal:
"""把第 index 个分项的单价改成 unit_price,重算合价、合计、开标一览表;正文里出现的旧总价一并替换。返回新总价。"""
items = spec["price"]["items"]
old_total = Decimal(spec["price"]["total"])
items[index]["unit_price"] = f"{unit_price:.2f}"
lines = [Decimal(str(i["qty"])) * Decimal(i["unit_price"]) for i in items]
total = sum(lines, Decimal(0)).quantize(Decimal("0.01"))
spec["price"]["total"] = f"{total:.2f}"
table = next(b for b in blocks(spec) if b.get("price_items"))
row = table["rows"][index]
row["cells"][-2], row["cells"][-1] = fmt(unit_price), fmt(lines[index].quantize(Decimal("0.01")))
pairs = [(fmt(old_total), fmt(total)), (rmb_upper(old_total), rmb_upper(total))]
for b in blocks(spec):
if b.get("type") == "table":
for r in b["rows"]:
r["cells"] = [_swap(c, pairs) for c in r["cells"]]
elif "text" in b:
b["text"] = _swap(b["text"], pairs)
if "lines" in b:
b["lines"] = [_swap(x, pairs) for x in b["lines"]]
return total
def _swap(text: str, pairs: list[tuple[str, str]]) -> str:
for a, b in pairs:
text = text.replace(a, b)
return text
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("clean")
ap.add_argument("out")
ap.add_argument("kind", choices=sorted(VARIANTS))
ap.add_argument("--variant")
a = ap.parse_args()
variant = a.variant or VARIANTS[a.kind][0]
src = OUT / a.clean
spec = json.loads((src / "spec.json").read_text())
manifest = json.loads((src / "manifest.json").read_text())
profile = json.loads((OUT / "tenders" / f"{spec['tender']}.json").read_text())
items = spec["price"]["items"]
total = Decimal(spec["price"]["total"])
forms = [f for f in profile["forms"] if f.get("kind") == "price_items"]
caps = {x["name"]: Decimal(str(x["ceiling"])) for x in (forms[0].get("items") or []) if x.get("ceiling")}
if a.kind in ("P01", "P02"):
ceiling = Decimal(str(spec["ceiling_total"]))
target = ceiling + 1 if variant == "超 1 元" else (ceiling * Decimal("1.01")).quantize(Decimal("0.01"))
i = next((n for n, x in enumerate(items) if Decimal(str(x["qty"])) == 1), 0)
qty = Decimal(str(items[i]["qty"]))
unit = Decimal(items[i]["unit_price"]) + ((target - total) / qty).quantize(Decimal("0.01"), rounding=ROUND_UP)
new_total = reprice(spec, i, unit)
assert new_total > ceiling, (new_total, ceiling)
snippet = fmt(new_total)
else:
i = next(n for n, x in enumerate(items) if x["name"] in caps)
unit = (caps[items[i]["name"]] * Decimal("1.01")).quantize(Decimal("0.01"))
new_total = reprice(spec, i, unit)
snippet = fmt(unit)
out = OUT / a.out
out.mkdir(parents=True, exist_ok=True)
spec["bid"] = a.out
manifest.update(
bid=a.out,
kind="sloppy",
density="price",
rule_defects=[
{"id": f"{a.kind}-1", "type": a.kind, "variant": variant, "t": snippet},
# 分项上限加起来常常就是限价:总价超限价时,被抬高的分项也超了上限,一并记
*(
[{"id": "P03-2", "type": "P03", "variant": "随总价超限", "t": fmt(Decimal(x["unit_price"]))}]
if a.kind != "P03" and (x := items[i])["name"] in caps and Decimal(x["unit_price"]) > caps[x["name"]]
else []
),
],
)
(out / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
(out / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
print(f"{a.out}:{a.kind}({variant}),总价 {fmt(total)} → {fmt(new_total)},原文片段「{snippet}」")
if __name__ == "__main__":
main()