后端 - 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>
80 lines
3.9 KiB
Python
80 lines
3.9 KiB
Python
"""查标的纯函数:检索、投标侧证据校验、问题级别(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
|