fix(server): 按 S01–S10 评测修复读标抽取(F1–F8、口径 Q1–Q4)

- 合并时待确认条目不再借正式条目进清单,没通过的类别记在 pending_categories
- 结构识别:章(按章跳过不相干的部分)、表后「上述……否则无效」说明、★ 节标题
- INVALID 补写法;输出用到上限 92% 按截断处理,拆到底时采用已有输出
- 格式范本正文、整表概括句不收;项目信息多看评标方法所在分块
- WPS 旧版 DOC 直接修正字体表字符集;表格按单元格切句(s3-v4)
- 口径:★ 节继承实质性、★ 定义为计分项时降为重要、「必须/应」声明记 keyword 规则、
  政府采购资格条件按 87 号令第六十三条带无效类别
- 提示词 substantive v5,qualification / schedule / tech_param v4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 23:19:30 +08:00
parent f99bb8b358
commit f98fd51128
17 changed files with 912 additions and 62 deletions

View File

@@ -402,3 +402,155 @@ def test_project_info_status() -> None:
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("投标文件未按上述要求签章的,投标无效。") # 这是一条无效情形,不是概括句