Files
bid-doctor/server/tests/test_units.py
YANG JIANKUAN e3f234ca0b feat: 读标可以走完一份文件;审计待确认区,修规则缺陷、提示词出新版本
读标操作(FR-M2-07、08)
- 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、
  采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、
  替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本,
  内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读
- 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖
- 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除
  (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、
  选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示

待确认审计(架构 §13 SK11)
- 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句
  (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文
  数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记
- 不放宽:引文最短 8 个字、编号必须对、证据全部通过
- 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2;
  引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本
- BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py
- 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57

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

305 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.

"""不需要数据库的单元测试:证据校验、格式识别、文字层。"""
from __future__ import annotations
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] == "id"
assert check_evidence({"sid": "p1s1", "quote": "投标人须具备"}, by_id)[0] == "quote" # 少于 8 个字
assert check_evidence({"sid": "p1s1", "quote": "这句话是模型改写的"}, by_id)[0] == "quote"
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"
# 必须从这一句开始,不能整段都在下一句里
assert check_evidence({"sid": "p1s1", "quote": "连续30分钟压力测试"}, by_id, {a.key: b})[0] == "quote"
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"
assert check_evidence({"sid": "p1s1", "quote": "价格扣除"}, by_id)[0] == "quote" # 只摘关键词照样退回
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)