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

@@ -297,7 +297,7 @@ async def test_rule_findings_are_written_with_citations_and_carried(user: User)
# 「让利后总价」和投标总价不同:多半另报了一个价,但不是「备选方案」那种明确的两个报价,按重要
assert rule["PRICE.MULTIPLE_QUOTES"]["severity"] == "important" and rule["FORM.BLANK_FIELD"]["severity"] == "hint"
assert any("报价形式" in x for x in run["coverage"]["checked"])
assert run["coverage"]["rule_set_version"] == "2026.11-r1"
assert run["coverage"]["rule_set_version"] == "2026.11-r2"
await user.post(f"/findings/{cap['id']}/resolve", json={"status": "ignored", "note": "大写栏已手工更正"})
again = await run_check(user, project_id, [bid["id"]])
@@ -305,3 +305,37 @@ async def test_rule_findings_are_written_with_citations_and_carried(user: User)
f for f in await findings(user, again["id"]) if (f["rule"] or {}).get("code") == "PRICE.CAPITAL_MISMATCH"
)
assert cap2["presence"] == "carried" and cap2["status"] == "ignored"
async def test_manual_checks_are_kept_per_project(user: User) -> None:
"""需人工核对:来自清单(按主题合并),勾选按项目存、写勾选人和时间,复查后还在(决策 P2)。"""
project_id, _, _ = await checked_project(user)
for title in ("须用CA数字证书加密投标文件", "开标后30分钟内在线解密投标文件", "本次采购不收取投标保证金"):
r = await user.post(
f"/projects/{project_id}/checklist/items", json={"categories": ["schedule"], "title": title}
)
assert r.status_code == 201, r.text
items = (await user.get(f"/projects/{project_id}/manual-checks")).json()["items"]
assert [(i["check_key"], i["checked"]) for i in items] == [
("rule:MANUAL.DECRYPT", False),
("rule:MANUAL.SIGN_ENCRYPT", False),
]
assert items[1]["requirement_text"] == "须用CA数字证书加密投标文件"
r = await user.put(
f"/projects/{project_id}/manual-checks",
json={"check_key": "rule:MANUAL.DECRYPT", "checked": True, "note": "已演练"},
)
assert r.status_code == 200, r.text
m = r.json()
assert m["checked"] and m["note"] == "已演练" and m["checked_by"]["name"] and m["checked_at"]
bad = await user.put(
f"/projects/{project_id}/manual-checks", json={"check_key": "rule:MANUAL.NOPE", "checked": True}
)
assert bad.status_code == 422
bid_id = (await user.get(f"/projects/{project_id}/check-preview")).json()["bid_documents"][0]["id"]
again = await run_check(user, project_id, [bid_id])
assert any("列入「需人工核对」2 条" in x for x in again["coverage"]["not_checked"])
kept = (await user.get(f"/projects/{project_id}/manual-checks")).json()["items"]
assert [i["checked"] for i in kept] == [True, False]

View File

@@ -166,3 +166,28 @@ def test_scanned_fallback_turns_missing_evidence_into_a_hint() -> None:
and "2 页扫描件" in spec.explanation
)
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到
def test_manual_topics_merge_by_topic_and_skip_irrelevant() -> None:
"""需人工核对(FR-M3-04):同一主题合并成一条;技术参数、免收保证金、围串标情形、点名要交的文件不算。"""
from bid_doctor.check import manual
items = [
{
"title": "投标文件递交截止 2027年3月5日 9:30",
"categories": ["schedule"],
"spec": {"schedule": {"at": "2027-03-05T09:30"}},
},
{"title": "电子投标文件须在截止时间前上传", "categories": ["schedule"]},
{"title": "未在规定时间内解密投标文件的投标无效", "categories": ["invalidity"]},
{"title": "须提供加盖电子签章的《开标一览表》", "categories": ["format_doc"]},
{"title": "须用CA数字证书加密投标文件", "categories": ["schedule"]},
{"title": "本项目免收投标保证金", "categories": ["schedule"]},
{"title": "数据库支持国密加密存储", "categories": ["tech_param"]},
{"title": "不同投标人的投标文件装订明显相似的投标无效", "categories": ["invalidity"]},
{"title": "纸质投标文件正本一份、副本四份,密封递交", "categories": ["schedule"]},
]
got = {t.key: [i["title"] for i in t.items] for t in manual.topics(items)}
assert list(got) == ["DECRYPT", "SIGN_ENCRYPT", "PAPER", "SUBMIT"]
assert got["SIGN_ENCRYPT"] == ["须用CA数字证书加密投标文件"]
assert got["SUBMIT"][0].startswith("投标文件递交截止") # 写明截止时间的做代表

View File

@@ -0,0 +1,93 @@
"""「报价」类清单项(C7):从招标文件的清单表读分项 / 单价上限,查标时和分项报价表比对。内容虚构。"""
from __future__ import annotations
from decimal import Decimal
from uuid import uuid4
from bid_doctor.check import applicability, match, rules
from bid_doctor.checklist import price
from bid_doctor.document.pipeline.text_layer import Sentence
COLS = [(80, 110), (110, 300), (300, 340), (340, 380), (380, 470)]
def row(seq: int, y: float, texts: list[str], page: int = 1) -> list[Sentence]:
out = []
for n, (t, (x0, x1)) in enumerate(zip(texts, COLS, strict=False)):
s = Sentence(
key=f"t:p{page}:s{seq + n}", page_no=page, seq=seq + n, text=t, quads=[[x0 + 2, y + 4, x1 - 2, y + 16]]
)
s.cell = [x0, y, x1, y + 20]
out.append(s)
return out
def line(seq: int, y: float, text: str, page: int = 1) -> Sentence:
return Sentence(key=f"t:p{page}:s{seq}", page_no=page, seq=seq, text=text, quads=[[74, y, 500, y + 12]])
def tender() -> list[Sentence]:
return [
line(1, 60, "采购包2:数据中心扩容"),
*row(10, 80, ["序号", "分项名称", "数量", "单位", "单价上限(万元)"]),
*row(20, 100, ["1", "服务器", "4", "台", "12.5"]),
*row(30, 120, ["2", "服务器机柜", "2", "个", "0.8"]),
line(40, 160, "采购包一览:各包的最高限价见下表,投标报价超过所投采购包最高限价的投标无效。"),
*row(50, 180, ["序号", "采购包名称", "数量", "单位", "最高限价(元)"]), # 采购包一级:不是分项
*row(60, 200, ["1", "采购包2", "1", "项", "600000"]),
]
def test_caps_from_tender_table() -> None:
caps = price.caps(tender())
assert [(c.lot_no, c.name, c.cap, c.per, c.qty) for c in caps] == [
("2", "服务器", Decimal("125000.0"), "unit", Decimal(4)),
("2", "服务器机柜", Decimal("8000.0"), "unit", Decimal(2)),
]
assert caps[0].title == "服务器:单价不超过 125,000.00 元" and caps[0].sentences[0].text == "服务器"
assert caps[0].spec()["price"] == {
"kind": "unit_cap",
"name": "服务器",
"cap": "125000.00",
"qty": "4",
"lot_no": "2",
}
def test_price_items_are_checked_by_rule_not_model() -> None:
d = applicability.decide({"categories": ["price"], "title": "服务器:单价不超过 125,000.00 元"}, {})
assert (d.match, d.skip) == (False, "price")
def bid_row(seq: int, y: float, texts: list[str]) -> list[Sentence]:
out = []
for n, (t, (x0, x1)) in enumerate(
zip(texts, [(80, 100), (100, 260), (260, 300), (300, 380), (380, 470)], strict=True)
):
s = Sentence(key=f"b:p1:s{seq + n}", page_no=1, seq=seq + n, text=t, quads=[[x0 + 2, y + 4, x1 - 2, y + 16]])
s.cell = [x0, y, x1, y + 20]
out.append(s)
return out
def test_item_over_cap_is_fatal_and_similar_names_do_not_match() -> None:
bid = [
*bid_row(1, 80, ["序号", "货物名称", "数量", "单价(元)", "合价(元)"]),
*bid_row(10, 100, ["1", "服务器", "4", "126,000.00", "504,000.00"]),
*bid_row(20, 120, ["2", "服务器机柜", "2", "7,900.00", "15,800.00"]),
]
item = uuid4()
facts = rules.Facts(
caps=[
rules.ItemCap("服务器", Decimal(125_000), "unit", item),
rules.ItemCap("服务器机柜", Decimal(8_000), "unit"),
]
)
hits, cov = rules.run(facts, [match.BidDoc(uuid4(), 1, "报价分册", bid)], [])
(h,) = [h for h in hits if h.code == "PRICE.OVER_CEILING_ITEM"]
assert h.severity == "fatal" and h.item == item and h.basis == f"cap:{item}" and "服务器" in h.title
assert "126,000.00 元" in h.explanation and "125,000.00 元" in h.explanation
assert not any("其余没有比对" in x for x in cov["not_checked"]) # 两条上限都对上了名称
assert rules._match_cap("服务器", [rules.ItemCap("服务器机柜", Decimal(1), "unit")]) is None