Files
bid-doctor/server/tests/test_units.py
YANG JIANKUAN f99bb8b358 feat(server): 参考 BidEngine 补五项:原因码、截断重跑、项目信息逐项表态、占位符、提示词规则对应校验
- 待确认记原因码(extract.FAILURES,存在 ai_values.failures),解析结果按原因计数
  (verify_failures);前端待确认条目显示具体原因
- 相邻分块重叠约 800 字;模型输出被截断或不是合法 JSON 时对半重跑(最多 3 层),
  拆到底仍不完整记 output_incomplete。修复:以前截断时整块条目被当成「没有结果」悄悄丢掉
- 项目基本信息提示词 v2:12 个字段逐项表态 found / ambiguous / not_found,跨分块合并为
  found / absent / needs_review(info_fields)
- 「____元」「××」「( )」「待填」等占位符不算取值
- 提示词 substantive v4,qualification / scoring / schedule / tech_param v3:每条禁止事项
  对应一个原因码,对照表在 prompts/README.md
- 顾问(Fable)深读参考项目的结论补进 ref-bid-engine-analysis.md §5;架构 §13 SK12
- 待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:59:12 +08:00

405 lines
20 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.

"""不需要数据库的单元测试:证据校验、格式识别、文字层。"""
from __future__ import annotations
import itertools
import pytest
from bid_doctor.checklist.extract import (
Doc,
Extracted,
amount_yuan,
check_basis,
check_evidence,
check_value,
is_marked,
merge_items,
parse_at,
)
from bid_doctor.core.errors import AppError
from bid_doctor.core.events import _sid # pyright: ignore[reportPrivateUsage]
from bid_doctor.document.formats import detect
from bid_doctor.document.pipeline.text_layer import Sentence, extract
from tests.conftest import FIXTURES
def sent(text: str, runs: list[tuple[int, int, str]] | None = None, seq: int = 1) -> Sentence:
return Sentence(key=f"d:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[0, 0, 1, 1]], fmt_runs=runs or [])
def test_evidence_checks() -> None:
s = sent("★投标人须具备有效的信息系统集成服务资质证书。")
by_id = {"p1s1": s}
assert check_evidence({"sid": "p1s1", "quote": "投标人须具备有效的信息"}, by_id) == (None, [s])
assert check_evidence({"sid": "p9s9", "quote": "投标人须具备有效的信息"}, by_id)[0] == "sid_unknown"
assert check_evidence({"sid": "第1页", "quote": "投标人须具备有效的信息"}, by_id)[0] == "sid_format"
assert check_evidence({"sid": "p1s1", "quote": "投标人须具备"}, by_id)[0] == "quote_too_short" # 少于 8 个字
assert check_evidence({"sid": "p1s1", "quote": "这句话是模型改写的"}, by_id)[0] == "quote_not_original"
def test_quote_may_run_into_next_sentence() -> None:
"""表格、跨页把一句切成两段时,引文可以从这一句接续到紧跟着的下一句,两句都算出处。"""
a = sent("支持不少于200个并发", seq=1)
b = sent("用户,连续30分钟压力测试成功率不低于99%。", seq=2)
by_id = {"p1s1": a, "p1s2": b}
assert check_evidence({"sid": "p1s1", "quote": "支持不少于200个并发用户"}, by_id, {a.key: b}) == (None, [a, b])
assert check_evidence({"sid": "p1s1", "quote": "支持不少于200个并发用户"}, by_id)[0] == "quote_not_original"
# 必须从这一句开始,不能整段都在下一句里
assert check_evidence({"sid": "p1s1", "quote": "连续30分钟压力测试"}, by_id, {a.key: b})[0] == "quote_not_original"
def test_split_listed_conditions() -> None:
from bid_doctor.checklist.extract import split_listed
heading = sent("25.3未实质性响应磋商文件的情形", seq=1)
items = [sent("25.3.1未成功解密电子响应文件的。", seq=2), sent("25.3.2未按要求上传电子响应文件的。", seq=3)]
d = doc(sentences=[heading, *items], parents={x.key: heading for x in items})
merged = Extracted(
task="substantive",
categories=["invalidity"],
summary="未实质性响应的情形",
sentences=items,
zone="formal",
basis="explicit_invalid",
importance="substantive",
)
parts = split_listed(merged, d)
assert [p.sentences[0].seq for p in parts] == [2, 3]
assert all(p.zone == "formal" for p in parts)
def doc(
declared: set[str] | None = None,
sentences: list[Sentence] | None = None,
parents: dict[str, Sentence] | None = None,
continuations: dict[str, Sentence] | None = None,
) -> Doc:
return Doc(
sentences=sentences or [],
parents=parents or {},
continuations=continuations or {},
notes=[],
declared=declared or set(),
)
def test_basis_check() -> None:
star = sent("★服务器内存不低于32GB。")
plain = sent("投标人应按要求提供售后服务方案。")
iu = sent("投标人须提供原厂授权书。", [(0, 12, "iu")])
assert check_basis("star", [star], [star], doc())
assert not check_basis("star", [plain], [plain], doc())
# 斜体下划线只在文件声明过时才算(07 F1)
assert not check_basis("italic_underline", [iu], [iu], doc())
assert check_basis("italic_underline", [iu], [iu], doc({"italic_underline"}))
invalid = sent("未提供的,其投标无效。", seq=2)
assert check_basis("explicit_invalid", [plain], [plain, invalid], doc())
assert not check_basis("unknown", [star], [star], doc())
def test_invalid_wordings_and_parent_heading() -> None:
"""「做无效响应处理」「视为非实质性响应」也算写明无效;列在无效情形标题下的条目,上级标题就是依据。"""
for text in ("否则做无效响应处理。", "将被视为非实质性响应。", "其投标将予以拒绝。"):
s = sent(text)
assert check_basis("explicit_invalid", [s], [s], doc()), text
heading = sent("25.3未实质性响应磋商文件的情形", seq=1)
item = sent("25.3.5供应商的最后报价超过了采购预算。", seq=9)
assert not check_basis("explicit_invalid", [item], [item], doc())
assert check_basis("explicit_invalid", [item], [item], doc(parents={item.key: heading}))
def test_marker_talk_is_not_a_clause() -> None:
"""国动样本 p67:偏离表表头「序号|实质性要求(斜体且有下划线部分或加*号条款)」被切成碎片,不能当条款。"""
frag = sent("号(斜体且有下划线部分或加", [(0, 13, "biu")])
assert is_marked(frag, {"italic_underline", "asterisk"}) is None
assert not check_basis("italic_underline", [frag], [frag], doc({"italic_underline"}))
def test_asterisk_needs_declaration() -> None:
s = sent("*供应商须提供原厂授权书。")
assert is_marked(s, set()) is None
assert is_marked(s, {"asterisk"}) == "asterisk"
assert is_marked(sent("3.*支持国产化CPU。"), {"asterisk"}) == "asterisk"
def test_value_checks() -> None:
s = [sent("响应文件递交截止时间:2026年12月1日9时30分(北京时间)。")]
assert parse_at("2026-12-01T09:30", s) == ("2026-12-01T09:30:00+08:00", True)
assert parse_at("2026-12-02T09:30", s)[1] is False # 日期对不上原文:进待确认
assert check_value("200", [sent("系统支持不少于200个并发连接。")])
assert not check_value("300", [sent("系统支持不少于200个并发连接。")])
assert check_value("0~5", [sent("类似业绩每个得1分,最高5分,满分0~5分。")])
assert amount_yuan("45", "万元") == "450000.00"
assert amount_yuan("8,000", "元") == "8000.00"
def test_parents_and_continuations() -> None:
from bid_doctor.document.pipeline.structure import parents
from bid_doctor.document.pipeline.text_layer import continuations
ss = [
sent("25.3未实质性响应磋商文件的情形", seq=1),
sent("25.3.1未加盖电子公章的。", seq=2),
sent("有下列情形之一的,投标无效:", seq=3),
sent("(1)未提交保证金的;", seq=4),
sent("26.评审", seq=5),
]
par = parents(ss)
assert par["d:p1:s2"].seq == 1 and par["d:p1:s4"].seq == 3 and "d:p1:s5" not in par
a = Sentence(key="d:p1:s9", page_no=1, seq=9, text="采购人有权", quads=[], open_end=True)
b = Sentence(key="d:p2:s1", page_no=2, seq=1, text="永久使用、修改、维护。", quads=[])
h = Sentence(key="d:p2:s1", page_no=2, seq=1, text="11.1供应商应按规定格式填报。", quads=[])
assert continuations([a, b]) == {"d:p1:s9": b}
assert continuations([a, h]) == {} # 下一页是新的编号条目:不是同一句
def test_merge_items_across_tasks() -> None:
s = sent("★服务器内存不低于32GB。")
sub = Extracted(
task="substantive",
categories=["invalidity"],
summary="内存要求",
sentences=[s],
zone="formal",
importance="substantive",
spec={"invalidity": {"source": "substantive_marker"}},
)
tech = Extracted(
task="tech_param",
categories=["tech_param", "invalidity"],
summary="内存不低于 32GB",
sentences=[s],
zone="formal",
importance="substantive",
spec={"tech_param": {"param": "内存", "op": ">=", "value": "32", "unit": "GB"}},
)
(m,) = merge_items([sub, tech])
assert m.summary == "内存不低于 32GB"
assert set(m.categories) == {"tech_param", "invalidity"}
assert set(m.spec) == {"tech_param", "invalidity"}
def test_marker_fallback() -> None:
assert is_marked(sent("★采用国产化CPU芯片。"), set()) == "star"
assert is_marked(sent("带★号的条款为实质性要求。"), set()) is None # 规则说明本身
iu = sent("投标人须提供原厂授权书。", [(0, 12, "iu")])
assert is_marked(iu, set()) is None
assert is_marked(iu, {"italic_underline"}) == "italic_underline"
def test_marked_text_for_model() -> None:
s = sent("须提供授权书,否则无效", [(3, 6, "iu"), (9, 11, "u")])
assert s.marked == "须提供<iu>授权书</iu>,否则<u>无效</u>"
def test_detect_formats() -> None:
pdf = (FIXTURES / "tender-mini.pdf").read_bytes()
d = detect(pdf, "采购文件.pdf")
assert (d.format, d.page_count, d.extension_mismatch) == ("pdf", 1, False)
assert detect(pdf, "采购文件.doc").extension_mismatch
with pytest.raises(AppError) as e:
detect(b"hello", "a.doc")
assert e.value.code == "file_format_unsupported"
def test_text_layer_normalizes_radicals_and_keys() -> None:
pages = extract("doc1", (FIXTURES / "tender-mini.pdf").read_bytes())
texts = [s.text for s in pages[0].sentences]
assert any("项目" in t for t in texts) and not any("⽬" in t for t in texts) # 「目」不是康熙部首
assert any("彩页" in t for t in texts)
keys = [s.key for s in pages[0].sentences]
assert keys[0] == "doc1:p1:s1" and len(set(keys)) == len(keys)
def test_stream_id_compare() -> None:
assert _sid("1700000000000-1") > _sid("999-5")
assert _sid("bad") == (0, 0)
def test_heading_is_context_not_the_requirement() -> None:
from bid_doctor.checklist.extract import without_headings
heading = sent("25.3未实质性响应磋商文件的情形", seq=1)
item = sent("25.3.1未成功解密电子响应文件的。", seq=2)
d = doc(sentences=[heading, item], parents={item.key: heading})
assert without_headings([heading, item], d) == [item]
assert without_headings([heading], d) == [heading] # 只引了标题:保留
# ---------------------------------------------------------------- 2026-11-03 待确认审计后的规则修正
def test_table_borders_are_not_underlines() -> None:
"""表格框线两端接着竖线,不算下划线;真正的下划线不接竖线(国动样本功能表被误认成斜体下划线条款)。"""
from types import SimpleNamespace
from bid_doctor.document.pipeline.text_layer import _underlines # pyright: ignore[reportPrivateUsage]
border = {"x0": 86.8, "x1": 501.6, "top": 379.5, "bottom": 379.5}
under = {"x0": 118.1, "x1": 188.8, "top": 327.9, "bottom": 327.9}
verticals = [{"x0": x, "x1": x, "top": 300.0, "bottom": 400.0} for x in (86.8, 501.6)]
page = SimpleNamespace(lines=[border, under, *verticals], rects=[])
assert _underlines(page) == [(118.1, 188.8, 327.9)]
def test_quote_ignores_our_markup_and_end_punctuation() -> None:
"""我们加进输入的格式标记和〔续下页〕不是原文,比对前去掉;短句整句复制时句末标点可以不带。原文文字不放宽。"""
s = sent("联合体享受10%价格扣除,用扣除后的价格参与评审。")
by_id = {"p1s1": s}
assert check_evidence({"sid": "p1s1", "quote": "联合体享受<u>10%</u>价格扣除"}, by_id)[0] is None
assert check_evidence({"sid": "p1s1", "quote": "用扣除后的价格参与评审〔续下页〕"}, by_id)[0] is None
short = sent("1.软件开发;")
assert check_evidence({"sid": "p1s1", "quote": "1.软件开发"}, {"p1s1": short})[0] is None
assert check_evidence({"sid": "p1s1", "quote": "软件开发"}, {"p1s1": short})[0] == "quote_too_short"
assert check_evidence({"sid": "p1s1", "quote": "价格扣除"}, by_id)[0] == "quote_too_short" # 只摘关键词照样退回
def test_chinese_numbers_in_values() -> None:
assert check_value("1", [sent("经采购人授权,每月至少开展一次运行检查。")])
assert check_value("12", [sent("培训不少于十二课时,每期不少于十二人。")])
assert not check_value("1", [sent("对接苏服办统一身份认证。")]) # 「统一」里的「一」不是数字
def test_value_in_same_row_or_heading() -> None:
"""评分表的分值可能在同一行隔几格的「分值」列,或者在评分因素的标题行里。"""
from bid_doctor.checklist.extract import _context # pyright: ignore[reportPrivateUsage]
row = Sentence(key="d:p1:s1", page_no=1, seq=1, text="提供完整方案的得满分。", quads=[[100, 200, 300, 214]])
points = Sentence(key="d:p1:s9", page_no=1, seq=9, text="4", quads=[[450, 205, 460, 213]])
other = Sentence(key="d:p1:s10", page_no=1, seq=10, text="5", quads=[[450, 300, 460, 312]])
head = Sentence(key="d:p1:s0", page_no=1, seq=0, text="5、视频演示(10分)", quads=[[90, 180, 200, 194]])
chunk = [head, row, sent("下一行的说明。", seq=2), sent("再下一行。", seq=3), points, other]
ctx = _context([row], chunk, parents={row.key: head})
assert points in ctx and other not in ctx and head in ctx
assert check_value("4", ctx) and check_value("10", ctx)
def test_chinese_numbered_section_parents() -> None:
"""「三、……否则投标无效」下面的 1. 2.,以及「1.报价符合性审查:」下面不带编号的句子(S02 第 20 页)。"""
from bid_doctor.document.pipeline.structure import ancestors, parents
ss = [
sent("三、符合条件(必须提供,否则投标无效)", seq=1),
sent("1.报价符合性审查:", seq=2),
sent("投标报价不得超过采购项目最高限价;", seq=3),
sent("2.《投标函》(加盖电子签章);", seq=4),
sent("四、综合评审评分项", seq=5),
sent("1.技术方案", seq=6),
]
par = parents(ss)
assert par["d:p1:s2"].seq == 1 and par["d:p1:s3"].seq == 2 and par["d:p1:s4"].seq == 1
assert par["d:p1:s6"].seq == 5
assert [p.seq for p in ancestors("d:p1:s3", par)] == [2, 1]
d = doc(sentences=ss, parents=par)
assert check_basis("explicit_invalid", [ss[2]], ss, d) # 依据在上级的上级
assert not check_basis("explicit_invalid", [ss[5]], ss, d)
def test_model_input_marks_only_for_marking_task() -> None:
from bid_doctor.checklist.extract import _render # pyright: ignore[reportPrivateUsage]
a = Sentence(key="d:p1:s1", page_no=1, seq=1, text="须提供授权书,采购人有权", quads=[], fmt_runs=[(3, 6, "iu")])
b = Sentence(key="d:p2:s1", page_no=2, seq=1, text="永久使用。", quads=[])
d = doc(sentences=[a, b], continuations={a.key: b})
assert _render([a, b], d).splitlines()[-2:] == ["[p1s1] 须提供授权书,采购人有权〔续下页〕", "[p2s1] 永久使用。"]
assert "<iu>授权书</iu>" in _render([a, b], d, marks=True)
# ---------------------------------------------------------------- 2026-11-03 参考 BidEngine 的五项改进
def test_placeholder_is_not_a_value() -> None:
from bid_doctor.checklist.extract import is_placeholder, value_issue
s = sent("最高限价:____万元,投标保证金:( )元。")
assert is_placeholder("____") and is_placeholder("××万元") and is_placeholder("( )") and is_placeholder("待填")
assert not is_placeholder("45") and not is_placeholder("0~5")
assert value_issue("____", [s]) == "placeholder_value" # 空白在原文里「找得到」,但不是值
assert value_issue("45", [s], "amount_unverified") == "amount_unverified"
assert value_issue("45", [sent("最高限价:45万元。")]) is None
def test_chunks_overlap_and_keep_continuations() -> None:
from bid_doctor.checklist import extract as x
ss = [sent(f"第{i}条要求的内容写在这里,长度大约四十个字左右,用来撑满分块。" * 3, seq=i) for i in range(1, 200)]
parts = x.chunks(ss, {})
assert len(parts) > 2
for a, b in itertools.pairwise(parts):
tail = b[: len(b) - len([s for s in b if s not in a])]
assert tail and tail == a[-len(tail) :] # 下一块开头是上一块的结尾几句
assert len(tail) <= x.OVERLAP_SENTENCES
# 每一句都至少在一个分块里
assert {s.key for p in parts for s in p} == {s.key for s in ss}
async def test_truncated_output_is_split_and_rerun(monkeypatch: pytest.MonkeyPatch) -> None:
"""模型输出被截断:把分块对半重跑,拆到底还不完整就记下来;不能当成「这一块没有结果」。"""
from uuid import UUID
from bid_doctor.ai import service as ai
from bid_doctor.checklist import extract as x
from bid_doctor.core.db import TenantContext
ss = [sent(f"★第{i}条:供应商须具备第{i}项能力,否则投标无效。", seq=i) for i in range(1, 9)]
sizes: list[int] = []
async def fake_run_json(ctx: object, **kw: object) -> dict[str, object]:
user = str(kw["user"])
n = user.count("\n[p")
sizes.append(n)
if kw["task"] == "extract.substantive" and n > 2:
raise ai.OutputIncomplete("truncated")
if kw["task"] == "extract.substantive":
lines = [ln for ln in user.splitlines() if ln.startswith("[p")]
items = [
{"summary": ln[8:20], "basis": "star", "evidence": [{"sid": ln[1:5].rstrip("]"), "quote": ln[7:]}]}
for ln in lines
]
return {"items": items}
return {"items": []} if kw["task"] != "extract.project_info" else {"fields": {}}
monkeypatch.setattr(x.ai, "run_json", fake_run_json)
d = doc(declared=set(), sentences=ss)
items, _, stats = await x.run(TenantContext(tenant_id=UUID(int=1), user_id=UUID(int=1)), UUID(int=2), d)
assert stats["chunks_split"] >= 3 and stats.get("output_incomplete", 0) == 0
assert {s.key for e in items if e.zone == "formal" for s in e.sentences} == {s.key for s in ss}
def test_project_info_status() -> None:
from bid_doctor.checklist.extract import _project_info # pyright: ignore[reportPrivateUsage]
a = sent("项目名称:某市智慧政务平台建设项目。", seq=1)
b = sent("最高限价:____万元。", seq=2)
chunk = [a, b]
found = {
"status": "found",
"value": "某市智慧政务平台建设项目",
"sid": "p1s1",
"quote": "项目名称:某市智慧政务平台建设项目。",
}
nf = {"status": "not_found", "value": None, "sid": None, "quote": None}
fields = {
"name": found,
"ceiling_total": {
"status": "found",
"value": "____",
"unit": "万元",
"sid": "p1s2",
"quote": "最高限价:____万元。",
},
"budget_total": {"status": "ambiguous", "value": None},
"code": {**nf, "sid": "p1s1"},
**{k: nf for k in ("purchaser", "agency", "procurement_method", "legal_regime", "eval_method")},
**{k: nf for k in ("accept_consortium", "accept_imported")},
} # sme_only 漏答
info = _project_info([(chunk, {"fields": fields})])
st = {k: v["status"] for k, v in info.status.items()}
assert st["name"] == "found" and st["purchaser"] == "absent"
assert info.status["ceiling_total"] == {"status": "needs_review", "codes": ["placeholder_value"]}
assert info.status["budget_total"]["codes"] == ["ambiguous"]
assert info.status["code"]["codes"] == ["not_found_with_evidence"]
assert info.status["sme_only"]["codes"] == ["missing_answer"]