读标操作(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>
305 lines
15 KiB
Python
305 lines
15 KiB
Python
"""不需要数据库的单元测试:证据校验、格式识别、文字层。"""
|
||
|
||
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)
|