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:
118
evals/check/price_defects.py
Normal file
118
evals/check/price_defects.py
Normal 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()
|
||||
@@ -27,6 +27,7 @@ from bid_doctor.ai.providers.base import near_limit
|
||||
from bid_doctor.ai.service import OutputIncomplete, provider
|
||||
from bid_doctor.check import applicability, findings, match, rules
|
||||
from bid_doctor.checklist import response_mode
|
||||
from bid_doctor.checklist import price
|
||||
from bid_doctor.checklist.extract import amount_yuan
|
||||
from bid_doctor.core.config import get_settings
|
||||
from bid_doctor.core.db import TenantContext
|
||||
@@ -153,6 +154,12 @@ def facts(tender: str, run: str, items: dict[UUID, dict[str, Any]]) -> rules.Fac
|
||||
c = amount_yuan(f.get("ceiling_total"), info["fields"].get("ceiling_total", {}).get("unit"))
|
||||
ceiling = Decimal(c) if c else None
|
||||
days, item = rules.required_validity(items)
|
||||
lot_no = str(profile.get("bidding_lot") or "").lstrip("0")
|
||||
caps = [
|
||||
rules.ItemCap(c.name, c.cap, c.per)
|
||||
for c in price.caps(gold.sentences(tender))
|
||||
if not c.lot_no or not lot_no or c.lot_no.lstrip("0") == lot_no
|
||||
]
|
||||
return rules.Facts(
|
||||
name=f.get("name"),
|
||||
code=f.get("code"),
|
||||
@@ -164,6 +171,7 @@ def facts(tender: str, run: str, items: dict[UUID, dict[str, Any]]) -> rules.Fac
|
||||
ceiling_note=note,
|
||||
validity_days=days,
|
||||
validity_item=item,
|
||||
caps=caps,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user