Files
bid-doctor/server/tests/test_check_units.py
YANG JIANKUAN 47c6a1258e feat(check): 文件要在、扫描件兜底(C10);响应方式存进清单并可改(C12)
- check/presence.py:「提供《授权委托书》」「须提交投标函」这类要求按标题里有没有这个文件名判断,不调模型。
- 扫描件兜底:要附证明材料的要求没找到、投标文件里有扫描件页时,改为提示级「可能在扫描件里,请人工核对」。
- 响应方式解析时识别、带依据句子存进 checklist.response_modes(迁移 0008);清单页显示、可改(写审计,
  重新解析不覆盖人工改过的);查标用存下来的。新接口 PUT /projects/{id}/checklist/response-modes/{category}。
- check_item_result.layer 加 presence(迁移 0007);契约同步默认接受的两种结论。

开发集 17 份:规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32)(docs/architecture/check-eval.md §5)。

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

169 lines
8.7 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"
def test_presence_names_and_find() -> None:
from bid_doctor.check import presence
assert presence.names("须提供加盖公章的《法定代表人授权委托书》,否则投标无效") == ["法定代表人授权委托书"]
assert presence.names("投标人须提交投标函及开标一览表") == ["投标函"]
heading = sent(3, "4.法定代表人授权委托书")
body = sent(4, "本授权委托书声明:我单位授权……(这是正文里顺带提到的,很长" + "," * 60 + ")")
assert presence.find("法定代表人授权委托书", [body, heading]) is heading
assert presence.find("营业执照", [heading]) is None
def test_scanned_fallback_turns_missing_evidence_into_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: {"categories": ["qualification"], "title": "营业执照"},
b: {"categories": ["tech_param"], "title": "内存"},
}
decisions = {a: applicability.Decision(True, evidence=True), b: applicability.Decision(True)}
docs = [match.BidDoc(uuid4(), 1, "资格证明册", [], [12, 13])]
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
)
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到