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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:44:22 +08:00

260 lines
12 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.

"""查标的规则检查(报价形式、形式要件):纯函数,投标文件用虚构的句子拼(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"),
}