Files
bid-doctor/server/tests/test_check_rules.py
YANG JIANKUAN 808259da87
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
fix(check): 修国动真实投标文件暴露的 6 处(规则集 r5)
国动真实投标文件查标一次(check-eval §14):44 个问题里有用的 9 个,
致命、重要两级都是误报。按决策 E3 全部修(§15):
- 响应错位:「完全满足,无偏离。」这类复合肯定写法整体算肯定
- 附加条件:「不以……另行收费」是承诺
- 扫描件:文字层 v5 记每页图片占比,带一行说明的扫描页也认;提示写明扫描页范围
- 不需要这家投标人回答的:无特定资格要求、总括性资格要求、中标后才做的事、
  投标文件没声称适用的联合体和价格扣除
- 「方案应明确 A、B、C……」逐项再检索
- 同一批送出的片段都可引用(引文照样核对);出处没过校验的不沿用

国动重新检查 44 → 11(全是提示,有用的 9 个都在);开发集召回 112 → 111
(模型重跑波动),致命级 25/32 不变,规矩的投标文件问题 239 → 213。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:16:12 +08:00

377 lines
18 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"),
}
def test_generalized_quotes_conditions_and_blanks() -> None:
"""留出集暴露的写法(check-eval §11)改成按结构判断;例句既不取自开发集,也不取自留出集。"""
d = doc(
[
*opening_table("2,000,000.00", "贰佰万元整"),
para(10, "若贵方接受分批付款,我方总价可下调为1,900,000.00元。", page=2),
para(11, "另附选配清单,选配部分合计报价800,000.00元。", page=2),
para(12, "本单位类似项目业绩合同金额2,500,000.00元。", page=2), # 业绩金额:不是另一个报价
para(13, "我方投标总价为2,000,000.00元,已含全部税费。", page=2), # 等于总价
para(14, "如遇原材料价格大幅上涨,供货价格由双方另行商定。", page=3),
para(15, "我方保留根据厂家排产情况调整交付周期的权利。", page=3),
para(16, "设备数量以届时实际供货清单为准,合同金额相应结算。", page=3),
para(17, "合同最终以双方签订的合同文本为准。", page=3), # 常见套话:不算
para(18, "日期选择控件精确到年月日。", page=4), # 说明:不算没填
para(19, "签署时间:年月日", page=4),
]
)
hits, _ = rules.run(rules.Facts(), [d], [])
got = sorted((h.code, h.evidence[0][1].seq) for h in hits)
assert got == [
("FORM.BLANK_FIELD", 19),
("FORM.CONDITION_ADDED", 14),
("FORM.CONDITION_ADDED", 15),
("FORM.CONDITION_ADDED", 16),
("PRICE.MULTIPLE_QUOTES", 10),
("PRICE.MULTIPLE_QUOTES", 11),
]
assert {h.severity for h in hits if h.code == "PRICE.MULTIPLE_QUOTES"} == {"important"}
def test_deviation_column_values_other_than_clear_ok_need_review() -> 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),
]
hits, _ = rules.run(rules.Facts(), [doc(rows)], [])
assert [(h.title, h.severity) for h in hits] == [("响应表里写了「基本一致」", "important")]
def test_variant_glyphs_compare_equal() -> None:
from bid_doctor.document.pipeline.text_layer import norm
assert norm("用戶 可以查看") == norm("用户可以查看")
def test_response_content_column_and_wrapped_cells_are_not_flagged() -> None:
"""有「偏离」栏时只看偏离栏:「投标响应」栏写「支持」「3年」是响应内容,不报;窄栏把「无偏离」折成两段,拼起来判断。"""
rows = [
cell(1, "序号", 80, 100, w=20),
cell(2, "招标要求", 110, 100, w=120),
cell(3, "投标响应", 240, 100),
cell(4, "偏离", 310, 100, w=30),
cell(5, "1", 82, 124, w=8),
cell(6, "质保期不少于3年", 110, 124, w=120),
cell(7, "3年", 240, 124),
cell(8, "无偏", 310, 124, w=30),
cell(9, "离", 310, 136, w=30),
cell(10, "2", 82, 160, w=8),
cell(11, "支持远程运维", 110, 160, w=120),
cell(12, "支持", 240, 160),
cell(13, "无偏离", 310, 160, w=30),
]
for c in rows:
x0, y0, x1, _ = c.quads[0]
c.cell = [
x0,
100 if y0 == 100 else 124 if y0 in (124, 136) else 160,
x1,
(124 if y0 == 100 else 160 if y0 in (124, 136) else 190),
]
hits, _ = rules.run(rules.Facts(), [doc(rows)], [])
assert hits == []
def test_long_response_mentioning_noncompliance_is_not_negative() -> None:
"""响应栏里一大段正常响应提到「不符合……时预警」,不是不满足;只有开头就写不满足才算。"""
from bid_doctor.check import deviation
def s(text: str) -> Sentence:
x = cell(1, text, 0, 0)
x.cell = [0, 0, 100, 20]
return x
long_ok = s("满足,对规划条件不符合村庄规划的用地范围进行高亮标记并预警")
assert deviation.judge([long_ok], "投标响应情况") == "ok"
assert deviation.judge([s("部分响应")], "投标响应情况") == "negative"
assert deviation.judge([s("不满足,现有版本仅支持单机部署")], "投标响应情况") == "negative"
assert deviation.judge([s("无偏"), s("离")], "偏离") == "ok"
def test_negated_extra_charge_is_a_promise_not_a_condition() -> None:
"""「不以……另行收费」「不以……为由另行计取」是承诺(国动真实投标文件误报,check-eval §14);
真的附加了条件(「另行收取」「另行协商」)照报。"""
d = doc(
[
para(1, "数据迁移和接口改造均包含在合同价中,不以正常适配工作另行收费。"),
para(2, "质保期内不以系统升级、兼容调整为由另行计取费用,售后服务全部免费。"),
para(3, "现场驻场服务费用按天另行收取,不含在本次报价中。"),
para(4, "超出质保期的维护费用由双方另行协商确定。"),
]
)
hits, _ = rules.run(rules.Facts(), [d], [])
assert sorted((h.code, h.evidence[0][1].seq) for h in hits) == [
("FORM.CONDITION_ADDED", 3),
("FORM.CONDITION_ADDED", 4),
]