Files
bid-doctor/server/tests/test_check_units.py
YANG JIANKUAN 4a5421a412 feat(check): 同一行按单元格;业绩条款不再误跳;重复条目只合并显示(C5 修订)
- 送给模型的「同一行」在有框线的表格里按单元格分行
- 「以合同签订时间为准」不再被当成「签订合同时才交」,要附合同的业绩评分项照常查
- 重复的要求各自判断,问题接口给出 duplicate_ids,查标页同组折叠显示、一起处理
- 决策 C5 修订记入 decisions.md;开发集召回 74%、致命级 25/32

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

296 lines
15 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("投标文件递交截止") # 写明截止时间的做代表
def test_direct_read_turns_negative_deviation_cell_into_finding() -> None:
"""偏离栏直读:证据句所在的表格行里偏离格写「偏离」,模型判了已响应也改判负偏离(check-eval §8)。"""
from bid_doctor.check import deviation
def c(seq: int, text: str, x0: float, y: float) -> Sentence:
s = Sentence(key=f"b:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[x0 + 2, y + 4, x0 + 90, y + 16]])
s.cell = [x0, y, x0 + 100, y + 30]
return s
req, resp, dev = c(1, "支持链路聚合", 100, 50), c(2, "满足,支持链路聚合", 200, 50), c(3, "偏离", 300, 50)
req2, resp2, dev2 = c(4, "支持堆叠", 100, 80), c(5, "满足,支持堆叠", 200, 80), c(6, "无偏离", 300, 80)
prose = Sentence(
key="b:p1:s7", page_no=1, seq=7, text="如不满足上述要求,按负偏离处理。", quads=[[74, 120, 500, 132]]
)
d = match.BidDoc(uuid4(), 1, "技术分册", [req, resp, dev, req2, resp2, dev2, prose])
results = {
uuid4(): match.Result("responded", "ai", "verified", evidence=[(d, resp)]),
uuid4(): match.Result("responded", "ai", "verified", evidence=[(d, resp2)]),
uuid4(): match.Result("responded", "ai", "verified", evidence=[(d, prose)]),
}
assert deviation.direct_read(results, [d]) == 1
first, second, third = results.values()
assert first.verdict == "negative_deviation" and [s.text for _, s in first.evidence] == [
"满足,支持链路聚合",
"偏离",
]
assert first.detail["model_verdict"] == "responded"
assert second.verdict == third.verdict == "responded"
def test_mismatch_read_only_when_response_cell_is_unrelated() -> None:
"""响应对象错位(D09):证据只是照抄的要求格、同一行响应格写的是别的内容 → 未找到响应。
指引、相关内容、只写满足的不算。"""
from bid_doctor.check import deviation
def c(seq: int, text: str, x0: float, y: float) -> Sentence:
s = Sentence(key=f"b:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[x0 + 2, y + 4, x0 + 90, y + 16]])
s.cell = [x0, y, x0 + 100, y + 30]
return s
rows = [
("支持云管理、VLAN隔离、标准交换三种工作模式", "显示器升降行程:150mm"),
("支持端口汇聚、端口流量统计", "满足,支持端口汇聚和端口流量统计"),
("批量数据处理在非工作时段内完成", "满足,详见技术方案第12页"),
("系统支持国产操作系统", "满足"),
]
sents, results, items = [], {}, {}
for n, (req, resp) in enumerate(rows):
a, b, dev = (
c(n * 3 + 1, req, 100, 50 + n * 40),
c(n * 3 + 2, resp, 200, 50 + n * 40),
c(n * 3 + 3, "无偏离", 300, 50 + n * 40),
)
sents += [a, b, dev]
k = uuid4()
items[k] = {"title": req, "requirement_text": req}
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
verdicts = [r.verdict for r in res.values()]
assert verdicts == ["not_found", "responded", "responded", "responded"]
first = next(iter(res.values()))
assert first.detail["mismatch"] == "显示器升降行程:150mm" and first.reason_code == "response_mismatch"
async def test_over_budget_items_are_undetermined_without_model_calls() -> None:
"""硬预算(C2):超出预算的批次不调模型,条目记「超出预算」,覆盖范围另外写明。"""
from bid_doctor.core.db import TenantContext
d, _ = bid()
items = {
uuid4(): {
"title": "服务器内存不低于32GB",
"requirement_text": "服务器内存不低于32GB",
"categories": ["tech_param"],
}
}
async def progress(_: float) -> None:
return None
ctx = TenantContext(tenant_id=uuid4(), user_id=uuid4())
results, stats = await match.run(ctx, uuid4(), items, [d], progress, budget=10)
(r,) = results.values()
assert (r.verdict, r.reason_code, stats.calls, stats.over_budget) == ("undetermined", "over_budget", 0, 1)
def test_award_time_wording_is_not_award_stage_evidence() -> None:
"""「以合同签订时间为准」是业绩的时间口径,不是「签订合同时才交」:要附合同的评分项照常查(check-eval §9)。"""
from bid_doctor.check import applicability
scoring = {
"categories": ["scoring"],
"title": "项目业绩(10 分)",
"requirement_text": "提供2023年以来类似项目业绩,以合同签订时间为准,需提供合同复印件。",
}
assert applicability.decide(scoring, {}).match
later = {**scoring, "requirement_text": "签订合同时提供业绩合同原件备查。"}
assert applicability.decide(later, {}).skip == "scoring"