fix(check): 修国动真实投标文件暴露的 6 处(规则集 r5)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

国动真实投标文件查标一次(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>
This commit is contained in:
YANG JIANKUAN
2026-11-05 13:16:12 +08:00
parent f5edc585f1
commit 808259da87
16 changed files with 373 additions and 21 deletions

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from uuid import uuid4
import pytest
from bid_doctor.check import findings, match
from bid_doctor.check.retrieval import Index, tokens
from bid_doctor.document.pipeline.text_layer import Sentence
@@ -158,15 +160,16 @@ def test_scanned_fallback_turns_missing_evidence_into_a_hint() -> None:
b: {"categories": ["tech_param"], "title": "内存"},
}
decisions = {a: applicability.Decision(True, evidence=True), b: applicability.Decision(True)}
docs = [match.BidDoc(uuid4(), 1, "资格证明册", [], [12, 13])]
docs = [match.BidDoc(uuid4(), 1, "资格证明册", [], [12, 13, 14, 20])]
match.scanned_fallback(results, items, decisions, docs)
spec = findings.spec(results[a], items[a])
assert (
results[a].verdict == "scanned"
and spec is not None
and spec.severity == "hint"
and "2 页扫描件" in spec.explanation
and "资格证明册第 12–14、20 页 是扫描件(共 4 页)" in spec.explanation
)
assert match.page_ranges([5, 3, 4, 9]) == "3–5、9"
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到
@@ -240,6 +243,11 @@ def test_mismatch_read_only_when_response_cell_is_unrelated() -> None:
("支持端口汇聚、端口流量统计", "满足,支持端口汇聚和端口流量统计"),
("批量数据处理在非工作时段内完成", "满足,详见技术方案第12页"),
("系统支持国产操作系统", "满足"),
# 几个肯定写法连着写(国动真实投标文件,check-eval §14):仍是肯定回答
("支持按区域、距离和服务类型筛选并排序", "完全满足,无偏离。"),
("后台支持批量导入并提示错误行", "符合,无负偏离;"),
("支持离线缓存最近浏览的十条记录", "完全满足,无偏离;页面截图详见附件三"),
("短信验证码有效期不超过五分钟", "完全满足,显示器升降行程:150mm"),
]
sents, results, items = [], {}, {}
for n, (req, resp) in enumerate(rows):
@@ -254,9 +262,18 @@ def test_mismatch_read_only_when_response_cell_is_unrelated() -> None:
results[k] = (a, b)
d = match.BidDoc(uuid4(), 1, "技术分册", sents)
res = {k: match.Result("responded", "ai", "verified", evidence=[(d, a)]) for k, (a, _) in results.items()}
assert deviation.mismatch_read(res, items, [d]) == 1
assert deviation.mismatch_read(res, items, [d]) == 2
verdicts = [r.verdict for r in res.values()]
assert verdicts == ["not_found", "responded", "responded", "responded"]
assert verdicts == [
"not_found",
"responded",
"responded",
"responded",
"responded",
"responded",
"responded",
"not_found",
]
first = next(iter(res.values()))
assert first.detail["mismatch"] == "显示器升降行程:150mm" and first.reason_code == "response_mismatch"
@@ -346,3 +363,111 @@ def test_direct_read_uses_header_column_and_flags_unclear_values() -> None:
assert results[b].verdict == "responded"
spec = findings.spec(results[a], {"title": "支持双电源", "importance": "substantive", "categories": ["tech_param"]})
assert spec is not None and spec.severity == "important" and spec.title.startswith("偏离栏需核对")
def test_scanned_page_with_caption_counts_as_empty() -> None:
"""扫描件常带一行文字说明(「合同扫描件第 2 页(共 15 页)」):按图片占比和字数一起判断(check-eval §14)。"""
from bid_doctor.document.pipeline.text_layer import Page
def page(chars: int, cover: float) -> str:
return Page(1, 595, 842, 0, chars, [], cover).text_source
assert page(0, 0.0) == "empty"
assert page(26, 0.28) == "empty" # 「2.1 营业执照」+ 执照扫描图
assert page(97, 0.53) == "empty" # 一段说明 + 整页合同扫描
assert page(30, 0.0) == "text" # 只有一行字的正文页(没有图)
assert page(495, 0.25) == "text" # 带架构图的正文页
assert page(179, 0.45) == "text" # 图多、字也不少
assert (
Page.from_json(
{"page_no": 1, "width_pt": 1, "height_pt": 1, "rotation": 0, "char_count": 30, "sentences": []}
).text_source
== "text"
) # v4 文字层没有图片占比
def test_applicability_skips_conditions_the_bidder_does_not_claim() -> None:
"""不需要这家投标人回答的条目(check-eval §14):写明没有的、总括性的、中标后才做的、
投标文件没声称适用的条件性要求(联合体、价格扣除)。声称了(投标文件里有对应文件)就照常查。"""
from bid_doctor.check import applicability
def it(title: str, cats: list[str], req: str = "") -> dict[str, object]:
return {"title": title, "categories": cats, "requirement_text": req or title}
items = [
it("本项目第2包无特定资格要求", ["qualification"]),
it("提交特定资格要求的证明材料", ["qualification"], "特定资格要求的证明材料(如有,必须提供)"),
it("投标人应满足招标文件规定的资格条件", ["qualification"]),
it("中标后十日内提交详细实施计划", ["tech_param"]),
it("联合体投标的,须提交联合体协议书", ["qualification"]),
it("残疾人福利性单位价格扣除", ["scoring"], "提供《残疾人福利性单位声明函》,给予价格扣除。"),
it("提供营业执照副本复印件", ["qualification"]),
]
ctx = applicability.context(items, ["营业执照副本复印件", "我公司不属于残疾人福利性单位。"])
got = [applicability.decide(i, {}, ctx).skip for i in items]
assert got == ["none_required", "none_required", "umbrella", "after_award", "not_claimed", None, None]
# 没有「无特定资格要求」时特定资格证明照查;不知道投标文件写了什么(ctx 为空)时条件性要求照查
assert applicability.decide(items[1], {}, applicability.Context(frozenset())).skip is None
assert applicability.decide(items[4], {}).skip is None
consortium = applicability.context(items, ["联合体协议书(附后)"])
assert applicability.decide(items[4], {}, consortium).skip is None
async def test_citation_from_another_item_in_the_same_batch_is_verified(monkeypatch: pytest.MonkeyPatch) -> None:
"""片段每批只送一次,模型引用了同一批别的条目的候选句:引文核对得上就算(check-eval §14);
没送过的编号照样是 sid_unknown。"""
from bid_doctor.core.db import TenantContext
net = sent(1, "系统部署于互联网区与电子政务外网区,两区之间只经前置交换。", page=7)
mem = sent(2, "服务器内存配置为64GB,满足不低于32GB的要求。", page=9)
d = match.BidDoc(uuid4(), 1, "投标文件", [net, mem])
a, b = uuid4(), uuid4()
items = {
a: {"title": "系统部署在电子政务外网区", "categories": ["tech_param"]},
b: {"title": "服务器内存不低于32GB", "categories": ["tech_param"]},
}
# 前提:每条只取 1 个候选句时,b 的候选句里没有 net(net 只是 a 的候选句)
assert [h.sentence for h in Index([net, mem]).search(match.query_text(items[b]), 1)] == [mem]
async def fake(ctx: object, **kw: object) -> dict[str, object]:
ev = {"sid": "d1p7s1", "quote": "系统部署于互联网区与电子政务外网区"}
return {
"results": [
{"item": "I1", "verdict": "responded", "evidence": [ev]},
{"item": "I2", "verdict": "responded", "evidence": [ev]}, # 引的是 a 的候选句
]
}
monkeypatch.setattr(match, "CANDIDATES", 1)
monkeypatch.setattr(match.ai, "run_json", fake)
async def progress(_: float) -> None:
return None
ctx = TenantContext(tenant_id=uuid4(), user_id=uuid4())
results, _ = await match.run(ctx, uuid4(), items, [d], progress)
assert results[a].verdict == "responded" and results[b].verdict == "responded"
assert results[b].evidence[0][1] is net
unknown = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p8s1", "quote": "x" * 10}]}, {}, [])
assert unknown.reason_code == "sid_unknown"
def test_outline_requirement_is_searched_item_by_item() -> None:
"""「方案应明确 A、B、C……」:列出的每一项再各检索一次(check-eval §14)。"""
item = {"requirement_text": "运维方案应包括巡检频次、备份策略、故障上报流程以及应急演练安排,并确保措施可落地。"}
assert match.outline_terms(item) == ["巡检频次", "备份策略", "故障上报流程", "应急演练安排"]
assert match.outline_terms({"requirement_text": "方案应明确工期。"}) == []
assert match.outline_terms({"requirement_text": "支持巡检、备份和应急演练。"}) == []
def test_reuse_skips_conclusions_whose_citation_failed() -> None:
"""出处没过校验的结论不沿用(复查时重新问模型);正常的结论按证据位置找回来。"""
from bid_doctor.check import reuse
d, allowed = bid()
(_, s) = allowed["d1p1s9"]
ok = match.Result("responded", "model", "verified", evidence=[(d, s)])
assert reuse.restore(reuse.snapshot(ok), [d], "r1") is not None
failed = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p9s9", "quote": "x" * 10}]}, allowed, [])
assert reuse.restore(reuse.snapshot(failed), [d], "r1") is None