"""不需要数据库的单元测试:证据校验、格式识别、文字层。"""
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 == "须提供授权书,否则无效"
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": "联合体享受10%价格扣除"}, 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 "授权书" 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"]
# ---------------------------------------------------------------- S01–S10 评测后的修正(extract-eval §3、§5)
def lines(*texts: str) -> list[Sentence]:
return [sent(t, seq=i + 1) for i, t in enumerate(texts)]
def test_chapters_skip_toc_and_references() -> None:
from bid_doctor.document.pipeline.structure import chapters
ss = lines(
"第一章招标公告", # 目录:章标题挨着
"第二章投标人须知",
"投标邀请",
"第一章招标公告",
"公告正文。",
"资格要求见第四章《采购需求》。", # 引用,不是章标题
"第二章投标人须知",
"须知正文。",
"第八章投标文件格式要求另行说明", # 正文里偶尔提到后面的章:章号要严格递增,取最长的序列
"须知正文。",
"第三章采购需求",
"参数。",
"第四章投标文件格式",
)
ch = chapters(ss)
assert [ch.get(s.key) for s in ss] == [
None, None, None, "notice", "notice", "notice", "instructions", "instructions", "instructions",
"instructions", "requirements", "requirements", "formats",
] # fmt: skip
def test_trailing_note_covers_rows_above() -> None:
from bid_doctor.document.pipeline.structure import trailing_notes
ss = lines(
"一、资格证明文件",
"营业执照或相关部门登记证明",
"财务状况报告",
"说明:",
"(1)上述资格证明文件1~2项必须全部按要求提供,否则投标文件无效。",
)
notes = trailing_notes(ss)
assert set(notes) == {ss[1].key, ss[2].key} # 到「一、」为止;「说明:」引导句不算
d = doc(sentences=ss)
d.trailing = notes
assert check_basis("explicit_invalid", [ss[1]], [ss[1]], d)
def test_star_section_heading() -> None:
from bid_doctor.checklist.extract import importance_of
from bid_doctor.document.pipeline.structure import star_sections
ss = lines(
"三、★商务要求", "1.交货期", "自合同签定之日起60工作日内安装调试完成。", "四、其他说明", "本项目不接受分包。"
)
scope = star_sections(ss)
assert set(scope) == {ss[1].key, ss[2].key} # 到下一个同级编号「四、」为止
d = doc(sentences=ss)
d.star_scope = scope
assert importance_of([ss[2]], d) == ("substantive", "marker")
assert check_basis("star", [ss[2]], [ss[2]], d)
assert importance_of([ss[4]], d) == ("normal", None)
def test_star_defined_as_scored_is_important() -> None:
"""文件把 ★ 定义成「核心技术参数」、不满足只扣分(S02):★ 是「重要」,不是实质性(口径 Q2)。"""
from bid_doctor.checklist.extract import build_doc, importance_of
star, define = sent("★设备采用国产自主可控CPU芯片。", seq=1), sent("备注:加★为核心技术参数;", seq=2)
scored = sent("每有一项核心技术参数不满足的减4分。", seq=3)
d = build_doc([star, define, scored])
assert is_marked(star, d.declared) is None
assert importance_of([star], d) == ("important", "marker")
assert [(n.markers, n.meaning) for n in d.notes] == [({"star"}, "important")]
# 没有计分证据:维持实质性(降级的代价是把致命显示成重要)
assert is_marked(star, build_doc([star, define]).declared) == "star"
# 有一处说 ★ 是实质性要求、或者带 ★ 的句子写着后果,就按实质性(顾问审读第 1 条的反例)
for extra in ("本文件中打“★”内容为实质性要求。", "打★号的参数负偏离的,视为未响应。"):
assert is_marked(star, build_doc([star, define, scored, sent(extra, seq=4)]).declared) == "star"
def test_heuristics_reject_lookalikes() -> None:
"""顾问审读第 2、5、6 条:目录隔着页码、参数行带 ★ 编号、否定写法的表后说明。"""
from bid_doctor.document.pipeline.structure import chapters, star_sections, trailing_notes
toc = lines("第一章招标公告", "3", "第二章投标人须知", "10", "第三章采购需求", "20")
assert chapters(toc) == {} # 只有目录:认不出可靠的章,就不跳过
ss = lines("★5、采用2*15W功放芯片,声音清晰;", "符合参数要求的产品彩页复印件。", "6、输出功率≥30W。")
assert star_sections(ss) == {} # 参数行不是节标题
ss = lines("三、商务要求(★为实质性要求)", "1.交货期30天。")
assert star_sections(ss) == {} # ★ 不紧挨编号:是图例,不是节标题
ss = lines("一、参数表", "内存不低于32GB", "以上参数为非实质性要求,不满足不导致投标无效。")
assert trailing_notes(ss) == {}
def test_pending_item_does_not_upgrade_formal_item() -> None:
"""待确认的条目不能借同一句的正式条目进正式清单(评测 §3.1:S07 普通参数被标成实质性)。"""
s = sent("支持SM2、SM3、SM4国密算法。")
tech = Extracted(task="tech_param", categories=["tech_param"], summary="国密算法", sentences=[s], zone="formal")
sub = Extracted(
task="substantive", categories=["invalidity"], summary="国密算法", sentences=[s], zone="pending",
importance="substantive", failures=["basis_unmatched"],
) # fmt: skip
(merged,) = merge_items([tech, sub])
assert (merged.categories, merged.importance, merged.zone) == (["tech_param"], "normal", "formal")
def test_near_output_limit_counts_as_incomplete() -> None:
from bid_doctor.ai.providers.base import near_limit
assert near_limit(7757) and near_limit(8192) # S03:7757 / 8192 自己收了尾
assert not near_limit(5000) and not near_limit(None)
def test_wps_doc_font_charset() -> None:
from bid_doctor.document.pipeline.convert import fix_wps_doc_charset
name = "宋体".encode("utf-16-le") + b"\x00\x00"
ffn = bytes([40 + len(name) - 1, 0, 0x90, 0x01, 0x02, 0]) + bytes(34) + name # chs = 02(SYMBOL)
fixed = fix_wps_doc_charset(b"\xd0\xcf" + ffn + b"\x00" * 8)
assert fixed[2 + 4] == 0x86
assert fix_wps_doc_charset(b"\xd0\xcf" + ffn.replace(b"\x02", b"\x00", 1)) == b"\xd0\xcf" + ffn.replace(
b"\x02", b"\x00", 1
) # 字符集本来没错:不动
def test_pending_category_kept_on_formal_item() -> None:
"""待确认的「无效」类别不并进正式条目,但也不丢:记在正式条目上(顾问审读第 3 条)。"""
s = sent("25.3.1投标人未按要求提供资格证明文件的。")
qual = Extracted(
task="qualification", categories=["qualification"], summary="资格证明", sentences=[s], zone="formal"
)
sub = Extracted(
task="substantive", categories=["invalidity"], summary="资格证明", sentences=[s], zone="pending",
importance="substantive", failures=["quote_not_original"],
) # fmt: skip
(merged,) = merge_items([qual, sub])
assert merged.categories == ["qualification"]
assert merged.ai_values["pending_categories"] == {"invalidity": ["quote_not_original"]}
def test_template_and_blanket_patterns() -> None:
from bid_doctor.checklist.extract import BLANKET, TEMPLATE
assert TEMPLATE.match("二、我单位具有良好的商业信誉和健全的财务会计制度。")
assert not TEMPLATE.match("投标人须具有良好的商业信誉和健全的财务会计制度。")
assert BLANKET.search("3投标人《资格证明文件》有任何一项不符合《资格审查要求》的,其投标无效。")
assert BLANKET.search("(1)上述资格证明文件1~6项必须全部提供,否则投标无效。")
assert not BLANKET.search("投标文件未按上述要求签章的,投标无效。") # 这是一条无效情形,不是概括句