- 响应方式:「逐条响应」要说的是要求 / 参数 / 偏离,声明函填写说明不算;「否则视为无效响应」不算默认接受 - 偏离栏按「是不是明确满足」判断:偏离栏、响应情况栏一起看,其他短写法请人核对(最高重要);表体序号认「技-86」等 - 另报价、附加条件按结构判断(另一个不同的总价金额、保留 / 另行 / 调整报价等) - 空白栏:「精确到年月日」不算没填 - 比对时折叠异体字(戶→户等),原文照存 - 记录决策 E2:先修再跑国动真实投标文件 开发集召回 75%、规矩投标文件问题数不变;留出集参考数字见 check-eval §12(已看过,仅参考)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
359 lines
16 KiB
Python
359 lines
16 KiB
Python
"""查标的规则检查(报价形式、形式要件):纯函数,投标文件用虚构的句子拼(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"
|