feat(check): 规则检查(报价形式、形式要件、响应表),表格按单元格分行

- check/rules.py(规则集 2026.11-r1,不调模型):大小写金额、分项合计、超最高限价、
  多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;
  每条挂投标原文,有依据的挂招标侧出处;缺限价、截止时间、有效期要求时写进覆盖范围
- 文字层的句子带上单元格框,表格按单元格上沿分行
- 按 Fable 审读意见收紧致命级:认不准的降为重要或提示
- 评测:score.py 评规则问题;P07 注入记录改为空白处前后原文,剔除注入到人员表的 D10
- 上传页的检查范围文案同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 16:44:22 +08:00
parent 47c6a1258e
commit db1b1f6f9f
17 changed files with 1792 additions and 20 deletions

View File

@@ -0,0 +1,259 @@
"""查标的规则检查(报价形式、形式要件):纯函数,投标文件用虚构的句子拼(rules.py,GUIDELINES §3.2)。
措辞故意和评测集的开发词库不同:规则按招投标文件的通用写法写,不按评测集的句子写。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import uuid4
from bid_doctor.check import match, rules
from bid_doctor.document.pipeline.text_layer import Sentence
def cell(seq: int, text: str, x: float, y: float, page: int = 1, w: float = 60) -> Sentence:
return Sentence(key=f"b:p{page}:s{seq}", page_no=page, seq=seq, text=text, quads=[[x, y, x + w, y + 12]])
def para(seq: int, text: str, page: int = 1) -> Sentence:
return cell(seq, text, 74, 30 + seq * 24, page, 440)
def doc(sentences: list[Sentence], header: list[Sentence] | None = None) -> match.BidDoc:
return match.BidDoc(uuid4(), 1, "商务分册", sentences, [], header or [])
def codes(hits: list[rules.Hit]) -> list[str]:
return sorted(h.code for h in hits)
def test_upper_amount_and_day_counts() -> None:
assert rules.upper_amount("壹佰贰拾贰万柒仟陆佰元整") == Decimal(1_227_600)
assert rules.upper_amount("壹亿零伍佰万元零伍角") == Decimal("105000000.5")
assert rules.upper_amount("拾万元整") == Decimal(100_000)
assert rules.validity_days("投标有效期为开标之日起一百二十(120)天。") == [120]
assert rules.validity_days("本投标文件自投标截止之日起三个月内有效。") == [90]
assert rules.validity_days("投标有效期内中标的,须在30日内签订合同。") == [] # 「有效期内」后面的天数不是有效期
def opening_table(lower: str, upper: str) -> list[Sentence]:
return [
cell(1, "项目名称", 80, 100),
cell(2, "投标总价(小写,元)", 220, 100),
cell(3, "投标总价(大写)", 380, 100),
cell(4, "某市数据中心扩容项目", 80, 124),
cell(5, lower, 220, 124),
cell(6, upper, 380, 124),
]
def test_capital_mismatch_and_over_ceiling() -> None:
d = doc(opening_table("2,050,000.00", "贰佰万元整"))
hits, _ = rules.run(rules.Facts(ceiling=Decimal(2_000_000)), [d], [])
assert codes(hits) == ["PRICE.CAPITAL_MISMATCH", "PRICE.OVER_CEILING_TOTAL"]
over = next(h for h in hits if h.code == "PRICE.OVER_CEILING_TOTAL")
assert over.severity == "fatal" and over.basis == "ceiling" and over.evidence[0][1].text == "2,050,000.00"
assert "建议" not in over.explanation and "应改为" not in over.explanation # 红线 6:不写数字建议
same = doc(opening_table("2,050,000.00", "贰佰零伍万元整"))
assert rules.run(rules.Facts(ceiling=Decimal(3_000_000)), [same], [])[0] == []
def item_table(rows: list[tuple[str, str, str, str]], total: str, page: int = 1) -> list[Sentence]:
out = [
cell(1, "序号", 80, 100, page, 20),
cell(2, "货物名称", 110, 100, page, 100),
cell(3, "数量", 260, 100, page, 30),
cell(4, "单价(元)", 320, 100, page),
cell(5, "合价(元)", 420, 100, page),
]
for i, (name, qty, unit, line) in enumerate(rows):
y, n = 124 + i * 22, 10 + i * 5
out += [
cell(n, str(i + 1), 82, y, page, 8),
cell(n + 1, name, 110, y, page, 100),
cell(n + 2, qty, 272, y, page, 8), # 窄数字居中,和表头「数量」不重叠
cell(n + 3, unit, 320, y, page),
cell(n + 4, line, 420, y, page),
]
out.append(cell(99, f"合计:{total}元", 80, 124 + len(rows) * 22 + 6, page, 300))
return out
def test_item_table_arithmetic() -> None:
ok = item_table(
[("服务器", "3", "100,000.33", "300,001.00"), ("存储", "1", "50,000.00", "50,000.00")], "350,001.00"
)
assert rules.run(rules.Facts(), [doc(ok)], [])[0] == [] # 单价舍入到分,合价差几分钱不算错
bad = item_table(
[("服务器", "3", "100,000.00", "310,000.00"), ("存储", "1", "50,000.00", "50,000.00")], "350,000.00"
)
(h,) = rules.run(rules.Facts(), [doc(bad)], [])[0]
assert h.code == "PRICE.SUM_MISMATCH" and "服务器" in h.explanation and "合计行" in h.explanation
assert "310,000.00" in [s.text for _, s in h.evidence]
def test_foreign_project_in_header_and_filled_fields() -> None:
facts = rules.Facts(name="某市数据中心扩容项目", code="ZC-2026-0815-A", purchaser="某市大数据局")
header = [
Sentence(
key=f"b:p{p}:s0",
page_no=p,
seq=0,
text="某县人民医院信息化改造工程",
quads=[[0, 0, 1, 1]],
location="header_footer",
)
for p in (1, 2)
]
body = [
para(1, "致:某市大数据局(采购人名称)"),
para(2, "参加ZC-2026-0815-A(项目编号)某市数据中心扩容项目(项目名称)的投标。"),
para(3, "我方承诺参加ZC-2025-0311-B(项目编号)的投标活动。"),
para(4, "类似业绩:项目名称:某省政务云平台运维服务项目,合同金额200万元。"), # 业绩不在填写位里,不查
]
hits, _ = rules.run(facts, [doc(body, header)], [])
assert codes(hits) == ["FORM.FOREIGN_PROJECT", "FORM.FOREIGN_PROJECT"]
head = next(h for h in hits if h.target.startswith("header:"))
assert head.detail["pages"] == [1, 2] and head.basis == "name"
assert any("ZC-2025-0311-B" in h.explanation for h in hits)
def test_blank_fields_and_kept_annotations() -> None:
d = doc(
[
para(1, "投标人名称(盖单位章):某某科技有限公司"), # 括注保留、名称已填:不算
para(2, "本授权书于年月日签署生效。"),
para(3, "被授权人:____(签字)"),
para(4, "法定代表人身份证号:/"), # 划除:不算
]
)
hits, _ = rules.run(rules.Facts(), [d], [])
assert [h.evidence[0][1].seq for h in hits] == [2, 3] and {h.severity for h in hits} == {"hint"}
def test_sign_dates_validity_conditions_and_extra_quotes() -> None:
facts = rules.Facts(deadline=date(2026, 12, 1), validity_days=90)
d = doc(
[
para(1, "签署日期:2026年12月3日"),
para(2, "日期:2026年11月31日"),
para(3, "证书发证日期:2027年1月1日"), # 不是落款日期
para(4, "本投标有效期为自投标截止之日起六十日。"),
para(5, "供货数量按实际需求结算,安装调试费用由采购人负责。"),
para(6, "培训费用已包含在报价中,不另行收费。"), # 承诺,不是附加条件
para(7, "第二套报价方案总价:1,880,000.00元"),
para(8, "我方不提交备选方案报价。"),
]
)
hits, _ = rules.run(facts, [d], [])
got = {(h.code, h.evidence[0][1].seq) for h in hits}
assert got == {
("FORM.DATE_AFTER_DEADLINE", 1),
("FORM.DATE_AFTER_DEADLINE", 2),
("FORM.VALIDITY_SHORT", 4),
("FORM.CONDITION_ADDED", 5),
("PRICE.MULTIPLE_QUOTES", 7),
}
bad_date = next(h for h in hits if h.evidence[0][1].seq == 2)
assert bad_date.title == "落款日期写法有误"
def test_response_table_negative_and_dedupe() -> None:
rows = [
cell(1, "实质性要求响应表", 74, 76, w=200),
cell(2, "序号", 80, 100, w=20),
cell(3, "实质性要求", 110, 100, w=150),
cell(4, "响应情况", 300, 100),
cell(5, "1", 82, 124, w=8),
cell(6, "“交货期”的响应情况", 110, 124, w=150),
cell(7, "完全响应", 300, 124),
cell(8, "2", 82, 146, w=8),
cell(9, "“付款方式”的响应情况", 110, 146, w=150),
cell(10, "部分满足", 300, 146),
cell(11, "3", 82, 168, w=8),
cell(12, "“质保期”的响应情况", 110, 168, w=150),
]
d = doc(rows)
hits, _ = rules.run(rules.Facts(), [d], [])
assert [(h.title, h.severity) for h in hits] == [
("响应表里写了「部分满足」", "fatal"),
("响应表里有一行没有填响应情况", "important"), # 「空着」可能是版面没认准:不给致命
]
covered = {("商务分册", 1, "部分满足")} # 响应匹配已经按清单项报了这一句
assert [h.title for h in rules.drop_covered(hits, covered)] == ["响应表里有一行没有填响应情况"]
def test_missing_facts_go_to_coverage() -> None:
_, cov = rules.run(rules.Facts(), [doc([para(1, "我方承诺按招标文件要求提供服务。")])], [])
text = ";".join(cov["not_checked"])
for want in ("没有认出开标一览表", "最高限价", "投标截止时间", "投标有效期", "项目名称"):
assert want in text
def boxed(seq: int, text: str, cell: list[float], dy: float = 0) -> Sentence:
"""有框线表格里的句子:文字框可以比单元格上沿低(垂直居中),分行按单元格。"""
x0, top, x1, _ = cell
s = cell_sentence = Sentence(
key=f"b:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[x0 + 2, top + 2 + dy, x1 - 2, top + 14 + dy]]
)
cell_sentence.cell = cell
return s
def test_real_layout_negatives() -> None:
"""真实投标文件常见、但不该报的写法(Fable 审读提出的反例)。"""
# 分组小计不算进明细;金额栏叫「投标报价」的分项表不是开标一览表,几行金额不算「多个总价」
rows = item_table([("服务器", "2", "10,000.00", "20,000.00"), ("交换机", "1", "5,000.00", "5,000.00")], "25,000.00")
rows.insert(-1, cell(90, "硬件小计", 110, 175, w=100))
rows.insert(-1, cell(91, "25,000.00", 420, 175))
multi = [
cell(1, "年度", 80, 300, page=2),
cell(2, "投标报价(元)", 220, 300, page=2),
cell(3, "第一年", 80, 324, page=2),
cell(4, "100,000.00", 220, 324, page=2),
cell(5, "第二年", 80, 348, page=2),
cell(6, "120,000.00", 220, 348, page=2),
]
# 响应表:短格垂直居中,文字比编号低半行,但在同一个单元格行里
resp = [
boxed(20, "序号", [80, 400, 100, 420]),
boxed(21, "符合性审查要求", [100, 400, 300, 420]),
boxed(22, "响应情况", [300, 400, 380, 420]),
boxed(23, "1", [80, 420, 100, 460]),
boxed(24, "投标人须承诺按期交货,交货期不超过合同签订后三十日", [100, 420, 300, 460]),
boxed(25, "完全响应", [300, 420, 380, 460], dy=14),
]
header = [
Sentence(
key="b:p1:s0", page_no=1, seq=0, text="第三章 技术服务方案", quads=[[0, 0, 1, 1]], location="header_footer"
)
]
body = [
para(30, "致:贵单位"),
para(31, "附件:原厂报价单。本报价有效期30天,过期另行报价。", page=3),
para(32, "优惠后报价:25,000.00元", page=3),
para(33, "日 期:二〇二六年十一月二十日", page=3),
]
facts = rules.Facts(
name="某市数据中心扩容项目", purchaser="某市大数据局", deadline=date(2026, 12, 1), validity_days=60
)
hits, _ = rules.run(facts, [doc([*rows, *multi, *resp, *body], header)], [])
assert [(h.code, h.severity) for h in hits] == [("FORM.CONDITION_ADDED", "important")] # 「过期另行报价」照报
def test_validity_in_letter_table_and_chinese_dates() -> None:
page = [
para(1, "投标函"),
cell(2, "投标有效期", 80, 200),
cell(3, "四十五天", 220, 200),
para(4, "落款日期:二〇二六年十二月三日"),
]
hits, _ = rules.run(rules.Facts(deadline=date(2026, 12, 1), validity_days=60), [doc(page)], [])
assert {(h.code, h.severity) for h in hits} == {
("FORM.VALIDITY_SHORT", "fatal"),
("FORM.DATE_AFTER_DEADLINE", "hint"),
}