Files
bid-doctor/server/tests/test_check_units.py
YANG JIANKUAN cb0c2fcafe feat(check): 响应方式识别(C12)和「这条要不要投标人回答」;评测集纠错
- checklist/response_mode.py:按类别(技术 / 商务)识别招标文件的响应方式(逐条响应 / 默认接受),规则优先;
  两种写法冲突按逐条,认不出按逐条并写进覆盖范围。
- check/applicability.py:无效情形(★ / 斜体下划线标注的条款除外)、日期与递交、没要求附材料的评分标准、
  无需提供证明或由采购人查询的资格,不逐条找响应,写进覆盖范围;要求附证明材料按原文判断,中标后才交的不算。
- 默认接受的要求没找到响应:一般要求记 implicit_accepted、不出问题;实质性要求记 implicit_review、提示级
  人工核对;要附证明材料的不免(迁移 0006)。
- match.py:修同一行单元格又是别的条目候选句时的 KeyError(每条清单项单独记候选句,句子从总表里查)。
- 评测集:注入器同一要求的所有表格行一起改;散文、附件、格式文件里也响应了的要求不挂漏响应类问题;
  错位响应必须是另一个参数;复核判不成立的单元不再挂;regen.py 按固定种子重新生成。

开发集 17 份:规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32
(docs/architecture/check-eval.md §4)。

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

134 lines
7.1 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.

"""查标的纯函数:检索、投标侧证据校验、问题级别(m3-plan §2)。"""
from __future__ import annotations
from uuid import uuid4
from bid_doctor.check import findings, match
from bid_doctor.check.retrieval import Index, tokens
from bid_doctor.document.pipeline.text_layer import Sentence
def sent(seq: int, text: str, page: int = 1) -> Sentence:
return Sentence(key=f"doc:p{page}:s{seq}", page_no=page, seq=seq, text=text, quads=[[0, 0, 1, 1]])
def test_tokens_keep_number_unit_and_model_names() -> None:
t = tokens("服务器内存不低于32GB,支持10GE和硬件BFD")
assert "32gb" in t and "10ge" in t and "bfd" in t and "内存" in t
def test_bm25_ranks_the_responding_sentence_first() -> None:
index = Index(
[
sent(1, "我公司提供7×24小时售后服务。"),
sent(2, "服务器内存配置为64GB,并提供产品彩页。"),
sent(3, "交换机支持硬件BFD,最小发送间隔3.3毫秒。"),
]
)
(top, *_) = index.search("交换机支持硬件BFD 最小报文发送间隔不大于50毫秒", 3)
assert top.sentence.seq == 3
assert index.search("完全无关的词语组合", 3) == []
def bid() -> tuple[match.BidDoc, dict[str, tuple[match.BidDoc, Sentence]]]:
s = sent(9, "服务器内存不低于32GB的要求:我方服务器内存配置为64GB。")
d = match.BidDoc(uuid4(), 1, "技术分册", [s])
return d, {"d1p1s9": (d, s)}
def test_responded_needs_verified_evidence() -> None:
"""模型说已响应,但证据没通过:不能判已响应,记「未找到响应」并带原因码(C3)。"""
_, allowed = bid()
ok = match.judge(
{"verdict": "responded", "evidence": [{"sid": "d1p1s9", "quote": "我方服务器内存配置为64GB。"}]}, allowed, []
)
assert ok.verdict == "responded" and ok.evidence_status == "verified" and len(ok.evidence) == 1
no_ev = match.judge({"verdict": "responded", "evidence": []}, allowed, [])
assert (no_ev.verdict, no_ev.evidence_status, no_ev.reason_code) == ("not_found", "unverified", "evidence_missing")
rewritten = match.judge(
{"verdict": "responded", "evidence": [{"sid": "d1p1s9", "quote": "内存为64GB,满足要求。"}]}, allowed, []
)
assert rewritten.reason_code == "quote_not_original" and rewritten.detail["claimed_pages"] == [1]
other = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p2s1", "quote": "x" * 10}]}, allowed, [])
assert other.reason_code == "sid_unknown"
missing = match.judge(None, allowed, ["技术分册"])
assert (missing.verdict, missing.reason_code) == ("undetermined", "missing_answer")
def test_severity_comes_from_the_item_not_the_model() -> None:
assert findings.severity({"importance": "substantive", "categories": ["tech_param", "invalidity"]}) == "fatal"
assert findings.severity({"importance": "normal", "categories": ["qualification"]}) == "fatal"
assert findings.severity({"importance": "important", "categories": ["tech_param"]}) == "important"
assert findings.severity({"importance": "normal", "categories": ["scoring"]}) == "important"
assert findings.severity({"importance": "normal", "categories": ["tech_param"]}) == "hint"
def test_no_value_is_a_hint_and_unverified_is_explained() -> None:
item = {"title": "服务器内存", "importance": "substantive", "categories": ["tech_param"]}
nv = findings.spec(match.Result("no_value", "model", "verified"), item)
assert nv is not None and nv.severity == "hint" and nv.title.startswith("未给出具体值")
uv = findings.spec(
match.Result("not_found", "model", "unverified", "quote_not_original", detail={"claimed_pages": [3]}), item
)
assert uv is not None and uv.severity == "fatal" and "第 3 页" in uv.explanation and "没能核对" in uv.explanation
assert findings.spec(match.Result("responded", "model", "verified"), item) is None
def test_response_mode_by_category() -> None:
from bid_doctor.checklist import response_mode
sents = [
sent(1, "技术规格偏离表"),
sent(2, "投标人应将所有偏离的条目列入上表,未列入上表的视作投标人完全接受。"),
sent(3, "商务条款偏离表"),
sent(4, "投标人须对商务要求逐条作出响应。"),
]
m = response_mode.detect(sents)
assert (m["technical"].mode, m["technical"].status) == ("implicit", "found")
assert (m["commercial"].mode, m["commercial"].status) == ("explicit", "found")
both = response_mode.detect([sent(1, "须逐项作出响应。"), sent(2, "否则将认为投标人接受招标文件的要求。")])
assert (both["technical"].mode, both["technical"].status) == ("explicit", "conflict") # 写法冲突按逐条(C12)
none = response_mode.detect([sent(1, "本项目为货物采购。")])
assert (none["commercial"].mode, none["commercial"].status) == ("explicit", "unknown")
def test_applicability_skips_items_that_need_no_response() -> None:
from bid_doctor.check import applicability
from bid_doctor.checklist import response_mode
modes = response_mode.detect([sent(1, "未列入偏离表的技术要求视作投标人完全接受。")])
decide = applicability.decide
assert decide({"categories": ["invalidity"], "importance_basis": "tender_text"}, modes).skip == "condition"
assert decide({"categories": ["invalidity"], "importance_basis": "marker"}, modes).match # ★ 条款是要求
assert decide({"categories": ["schedule"]}, modes).skip == "schedule"
assert decide({"categories": ["scoring"]}, modes).skip == "scoring"
assert decide({"categories": ["scoring"], "evidence_required": "业绩合同"}, modes).match
assert decide({"categories": ["scoring"], "title": "业绩(2 分):提供近三年类似项目合同复印件"}, modes).match
assert (
decide({"categories": ["scoring"], "title": "提供检测报告,中标后签订合同前提供原件"}, modes).skip == "scoring"
)
assert decide({"categories": ["tech_param"], "title": "支持硬件BFD,提供官网截图证明"}, modes).evidence
assert decide({"categories": ["qualification"], "title": "未被列入信用中国失信名单"}, modes).skip == "no_proof"
d = decide({"categories": ["tech_param"], "title": "内存"}, modes)
assert d.match and d.mode == "implicit"
def test_implicit_not_found_is_accepted_but_substantive_gets_a_hint() -> None:
from uuid import uuid4
from bid_doctor.check import applicability
a, b = uuid4(), uuid4()
results = {a: match.Result("not_found", "model"), b: match.Result("not_found", "model")}
items = {a: {"importance": "normal"}, b: {"importance": "substantive", "title": "★双电源"}}
decisions = {a: applicability.Decision(True, mode="implicit"), b: applicability.Decision(True, mode="implicit")}
match.apply_modes(results, items, decisions)
assert results[a].verdict == "implicit_accepted" and findings.spec(results[a], items[a]) is None
spec = findings.spec(results[b], items[b])
assert results[b].verdict == "implicit_review" and spec is not None and spec.severity == "hint"