feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用

后端
- check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、
  状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。
- 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处;
  没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。
- 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型,
  漏答记 missing_answer,模型临时错误只影响那一批。
- 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点;
  失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。
- 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。
- 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。
- ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。
- SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。

前端
- 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、
  已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。

契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、
架构 §8 / §13(SK14)、AGENTS.md 同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 11:48:10 +08:00
parent 6ab42959df
commit e499de7486
56 changed files with 3735 additions and 183 deletions

View File

@@ -0,0 +1,79 @@
"""查标的纯函数:检索、投标侧证据校验、问题级别(m3-plan §2)。"""
from __future__ import annotations
from uuid import uuid4
from bid_doctor.check import findings, match
from bid_doctor.check.retrieval import Index, tokens
from bid_doctor.document.pipeline.text_layer import Sentence
def sent(seq: int, text: str, page: int = 1) -> Sentence:
return Sentence(key=f"doc:p{page}:s{seq}", page_no=page, seq=seq, text=text, quads=[[0, 0, 1, 1]])
def test_tokens_keep_number_unit_and_model_names() -> None:
t = tokens("服务器内存不低于32GB,支持10GE和硬件BFD")
assert "32gb" in t and "10ge" in t and "bfd" in t and "内存" in t
def test_bm25_ranks_the_responding_sentence_first() -> None:
index = Index(
[
sent(1, "我公司提供7×24小时售后服务。"),
sent(2, "服务器内存配置为64GB,并提供产品彩页。"),
sent(3, "交换机支持硬件BFD,最小发送间隔3.3毫秒。"),
]
)
(top, *_) = index.search("交换机支持硬件BFD 最小报文发送间隔不大于50毫秒", 3)
assert top.sentence.seq == 3
assert index.search("完全无关的词语组合", 3) == []
def bid() -> tuple[match.BidDoc, dict[str, tuple[match.BidDoc, Sentence]]]:
s = sent(9, "服务器内存不低于32GB的要求:我方服务器内存配置为64GB。")
d = match.BidDoc(uuid4(), 1, "技术分册", [s])
return d, {"d1p1s9": (d, s)}
def test_responded_needs_verified_evidence() -> None:
"""模型说已响应,但证据没通过:不能判已响应,记「未找到响应」并带原因码(C3)。"""
_, allowed = bid()
ok = match.judge(
{"verdict": "responded", "evidence": [{"sid": "d1p1s9", "quote": "我方服务器内存配置为64GB。"}]}, allowed, []
)
assert ok.verdict == "responded" and ok.evidence_status == "verified" and len(ok.evidence) == 1
no_ev = match.judge({"verdict": "responded", "evidence": []}, allowed, [])
assert (no_ev.verdict, no_ev.evidence_status, no_ev.reason_code) == ("not_found", "unverified", "evidence_missing")
rewritten = match.judge(
{"verdict": "responded", "evidence": [{"sid": "d1p1s9", "quote": "内存为64GB,满足要求。"}]}, allowed, []
)
assert rewritten.reason_code == "quote_not_original" and rewritten.detail["claimed_pages"] == [1]
other = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p2s1", "quote": "x" * 10}]}, allowed, [])
assert other.reason_code == "sid_unknown"
missing = match.judge(None, allowed, ["技术分册"])
assert (missing.verdict, missing.reason_code) == ("undetermined", "missing_answer")
def test_severity_comes_from_the_item_not_the_model() -> None:
assert findings.severity({"importance": "substantive", "categories": ["tech_param", "invalidity"]}) == "fatal"
assert findings.severity({"importance": "normal", "categories": ["qualification"]}) == "fatal"
assert findings.severity({"importance": "important", "categories": ["tech_param"]}) == "important"
assert findings.severity({"importance": "normal", "categories": ["scoring"]}) == "important"
assert findings.severity({"importance": "normal", "categories": ["tech_param"]}) == "hint"
def test_no_value_is_a_hint_and_unverified_is_explained() -> None:
item = {"title": "服务器内存", "importance": "substantive", "categories": ["tech_param"]}
nv = findings.spec(match.Result("no_value", "model", "verified"), item)
assert nv is not None and nv.severity == "hint" and nv.title.startswith("未给出具体值")
uv = findings.spec(
match.Result("not_found", "model", "unverified", "quote_not_original", detail={"claimed_pages": [3]}), item
)
assert uv is not None and uv.severity == "fatal" and "第 3 页" in uv.explanation and "没能核对" in uv.explanation
assert findings.spec(match.Result("responded", "model", "verified"), item) is None