Files
bid-doctor/server/tests/test_check_units.py
YANG JIANKUAN 81c58798d2 feat(check): 需人工核对(P2)和分项 / 单价上限(C7)
- 需人工核对:份数、密封、递交、CA 签章加密、解密、保证金这类文件里看不出来的要求,
  从正式清单按主题合并、带招标原文出处;manual-checks 接口按项目存勾选、写勾选人和时间,
  复查沿用;查标页加「需人工核对」一块,覆盖范围写明列了几条
- 「报价」类清单项:读标从采购需求的清单表规则读出分项 / 单价上限(checklist/price.py);
  查标交给规则 PRICE.OVER_CEILING_ITEM 比对分项报价表,不交给模型;规则集 2026.11-r2
- 版面工具(分页、分行、归栏)挪到 document/pipeline/layout.py,读标和查标共用
- 评测:price_defects.py 从规矩投标文件派生只带报价问题的 4 份(P01、P02、P03)

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

194 lines
10 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" # 技术参数不是证明材料,照常报未找到
def test_manual_topics_merge_by_topic_and_skip_irrelevant() -> None:
"""需人工核对(FR-M3-04):同一主题合并成一条;技术参数、免收保证金、围串标情形、点名要交的文件不算。"""
from bid_doctor.check import manual
items = [
{
"title": "投标文件递交截止 2027年3月5日 9:30",
"categories": ["schedule"],
"spec": {"schedule": {"at": "2027-03-05T09:30"}},
},
{"title": "电子投标文件须在截止时间前上传", "categories": ["schedule"]},
{"title": "未在规定时间内解密投标文件的投标无效", "categories": ["invalidity"]},
{"title": "须提供加盖电子签章的《开标一览表》", "categories": ["format_doc"]},
{"title": "须用CA数字证书加密投标文件", "categories": ["schedule"]},
{"title": "本项目免收投标保证金", "categories": ["schedule"]},
{"title": "数据库支持国密加密存储", "categories": ["tech_param"]},
{"title": "不同投标人的投标文件装订明显相似的投标无效", "categories": ["invalidity"]},
{"title": "纸质投标文件正本一份、副本四份,密封递交", "categories": ["schedule"]},
]
got = {t.key: [i["title"] for i in t.items] for t in manual.topics(items)}
assert list(got) == ["DECRYPT", "SIGN_ENCRYPT", "PAPER", "SUBMIT"]
assert got["SIGN_ENCRYPT"] == ["须用CA数字证书加密投标文件"]
assert got["SUBMIT"][0].startswith("投标文件递交截止") # 写明截止时间的做代表