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>
This commit is contained in:
@@ -356,3 +356,21 @@ def test_long_response_mentioning_noncompliance_is_not_negative() -> None:
|
||||
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),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user