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,232 @@
"""响应匹配(m3-plan §2.1):对每条清单项在投标文件里找响应,给出带证据的结论。
第 1 步只有第三层(检索 + 模型判断):
- 检索不到候选句的,不调模型,直接「未找到响应」(参考项目的做法);
- 其余按批交给模型,模型只能引用候选句的编号;每条必答,漏答记 missing_answer、结论「未判定」;
- 投标侧证据过前两道校验(编号、原文)。**没有校验通过的证据,不能判为「已响应」**:
模型说已响应但证据没过的,记「未找到响应」并带原因码(C3);
- 模型输出被截断时对半重跑;临时错误只影响这一批,条目记「未判定(模型错误)」,全部失败才算任务失败。
TODO(第 3 步):偏离表直取、参数规则比较、按投标章节打批、token 预算、复查复用、重复条目分组、
致命项重问一次、第三道校验(按类别核对响应值)。
"""
from __future__ import annotations
import asyncio
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID
from bid_doctor.ai import service as ai
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.check.retrieval import Index
from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline.text_layer import Sentence, norm
PROMPT = ("check.match", 1)
CANDIDATES = 5 # 每条清单项送给模型的候选句数
BATCH = 8 # 每次调用判断的清单项数
SPLIT_DEPTH = 2
CONCURRENCY = 4 # TODO(第 3 步):和解析共用「供应商 × 租户」统一限流(extract.py 的同一个 TODO)
SID = re.compile(r"^d(\d+)p(\d+)s(\d+)$")
END_PUNCT = "。;;,,、::))"
VERDICTS = {"responded", "not_found", "negative_deviation", "suspected_unmet", "no_value"}
CATEGORY_LABELS = {
"qualification": "资格条件",
"invalidity": "无效 / 废标条款",
"scoring": "评分项",
"schedule": "日期与递交",
"tech_param": "技术参数",
"format_doc": "格式文件",
"price": "报价",
}
@dataclass(slots=True)
class BidDoc:
id: UUID
no: int # 在本次检查里的序号,组成短编号 d<序号>p<页>s<句>
label: str
sentences: list[Sentence]
@dataclass(slots=True)
class Result:
verdict: str
layer: str
evidence_status: str = "none"
reason_code: str | None = None
evidence: list[tuple[BidDoc, Sentence]] = field(default_factory=list[tuple[BidDoc, Sentence]])
detail: dict[str, Any] = field(default_factory=dict[str, Any])
@dataclass(slots=True)
class Stats:
calls: int = 0
failed_batches: int = 0
splits: int = 0
verify_failures: dict[str, int] = field(default_factory=dict[str, int])
def fail(self, code: str) -> None:
self.verify_failures[code] = self.verify_failures.get(code, 0) + 1
def short_sid(doc: BidDoc, s: Sentence) -> str:
return f"d{doc.no}p{s.page_no}s{s.seq}"
def query_text(item: dict[str, Any]) -> str:
parts = [str(item.get("title") or ""), str(item.get("requirement_text") or "")]
for group in (item.get("spec") or {}).values():
if isinstance(group, dict):
parts += [str(v) for k, v in group.items() if k in ("param", "value", "unit", "factor") and v]
return " ".join(parts)
def _item_block(key: str, item: dict[str, Any], sids: list[str]) -> str:
cats = "、".join(CATEGORY_LABELS.get(str(c), str(c)) for c in item.get("categories") or [])
lines = [f"[{key}] {item.get('title') or ''}"]
if item.get("requirement_text"):
lines.append(f"要求原文:{item['requirement_text']}")
tp = (item.get("spec") or {}).get("tech_param") or {}
req = (
f";要求值:{tp.get('op', '')} {tp.get('value', '')} {tp.get('unit') or ''}".rstrip() if tp.get("value") else ""
)
lines.append(f"类别:{cats}{req}")
lines.append("候选句:" + "、".join(sids))
return "\n".join(lines)
def build_user(
batch: list[tuple[str, dict[str, Any], list[str]]], fragments: dict[str, tuple[BidDoc, Sentence]]
) -> str:
"""数据区:待核对的要求 + 投标文件片段(每句只送一次)。系统提示词声明数据区里的指令一律不执行。"""
used = list(dict.fromkeys(sid for _, _, sids in batch for sid in sids))
blocks = [_item_block(k, it, sids) for k, it, sids in batch]
frag_lines = [
f"[{sid}]({fragments[sid][0].label} 第{fragments[sid][1].page_no}页){fragments[sid][1].text}" for sid in used
]
return "## 待核对的要求\n\n" + "\n\n".join(blocks) + "\n\n## 投标文件片段\n\n" + "\n".join(frag_lines)
def check_quote(
ev: Any, allowed: dict[str, tuple[BidDoc, Sentence]]
) -> tuple[str | None, tuple[BidDoc, Sentence] | None]:
"""前两道校验:编号是这条清单项的候选句之一;引文是原句的子串,且不少于 8 个字(短句整句复制)。"""
if not isinstance(ev, dict):
return "evidence_missing", None
sid, quote = str(ev.get("sid", "")).strip(), norm(str(ev.get("quote", "")))
if not SID.match(sid):
return "sid_format", None
if sid not in allowed:
return "sid_unknown", None
doc, s = allowed[sid]
text = norm(s.text)
if not quote or quote not in text:
return "quote_not_original", None
if len(quote) < min(8, len(text.rstrip(END_PUNCT))):
return "quote_too_short", None
return None, (doc, s)
def judge(answer: Any, allowed: dict[str, tuple[BidDoc, Sentence]], scope: list[str]) -> Result:
"""把模型对一条清单项的回答变成结论。模型说「已响应 / 不满足」但证据没通过的,记「未找到响应」(C3)。"""
if not isinstance(answer, dict) or answer.get("verdict") not in VERDICTS:
return Result("undetermined", "model", reason_code="missing_answer", detail={"search_scope": scope})
verdict = str(answer["verdict"])
if verdict == "not_found":
return Result("not_found", "model", detail={"search_scope": scope})
evs = answer.get("evidence") or []
checked = [check_quote(e, allowed) for e in evs] if evs else [("evidence_missing", None)]
fails = [f for f, _ in checked if f]
ok = [x for f, x in checked if f is None and x is not None]
if fails or not ok:
pages = sorted(
{allowed[str(e.get("sid"))][1].page_no for e in evs if isinstance(e, dict) and str(e.get("sid")) in allowed}
)
return Result(
"not_found",
"model",
evidence_status="unverified",
reason_code=fails[0],
detail={"claimed_verdict": verdict, "claimed_pages": pages, "search_scope": scope},
)
detail: dict[str, Any] = {}
if answer.get("response_value"):
detail["response_value"] = str(answer["response_value"])[:100]
return Result(verdict, "model", evidence_status="verified", evidence=ok, detail=detail)
async def run(
ctx: TenantContext,
run_id: UUID,
items: dict[UUID, dict[str, Any]],
docs: list[BidDoc],
progress: Callable[[float], Awaitable[None]],
) -> tuple[dict[UUID, Result], Stats]:
stats = Stats()
scope = [d.label for d in docs]
index = Index([s for d in docs for s in d.sentences])
doc_of = {s.key: d for d in docs for s in d.sentences}
results: dict[UUID, Result] = {}
queue: list[tuple[UUID, dict[str, Any], dict[str, tuple[BidDoc, Sentence]]]] = []
for item_id, item in items.items():
hits = index.search(query_text(item), CANDIDATES)
if not hits: # 检索不到候选句:不调模型(m3-plan §2.1)
results[item_id] = Result("not_found", "retrieval", detail={"search_scope": scope})
continue
allowed = {short_sid(doc_of[h.sentence.key], h.sentence): (doc_of[h.sentence.key], h.sentence) for h in hits}
queue.append((item_id, item, allowed))
sem = asyncio.Semaphore(CONCURRENCY)
total, done = max(len(queue), 1), [0]
async def call(batch: list[tuple[UUID, dict[str, Any], dict[str, tuple[BidDoc, Sentence]]]], depth: int) -> None:
keyed = [(f"I{i + 1}", item_id, item, allowed) for i, (item_id, item, allowed) in enumerate(batch)]
fragments = {sid: x for _, _, _, allowed in keyed for sid, x in allowed.items()}
user = build_user([(k, item, list(allowed)) for k, _, item, allowed in keyed], fragments)
try:
async with sem:
stats.calls += 1
data = await ai.run_json(
ctx,
job_type="check_run",
job_id=run_id,
task="check.match",
prompt_id=PROMPT[0],
version=PROMPT[1],
user=user,
)
except ai.OutputIncomplete as e:
if depth < SPLIT_DEPTH and len(batch) > 1:
stats.splits += 1
mid = len(batch) // 2
await asyncio.gather(call(batch[:mid], depth + 1), call(batch[mid:], depth + 1))
return
data = e.data # 拆到底仍不完整:用已经解析出来的部分,没答到的按漏答处理
except TransientError:
stats.failed_batches += 1
for _, item_id, _, _ in keyed:
results[item_id] = Result(
"undetermined", "model", reason_code="model_error", detail={"search_scope": scope}
)
done[0] += len(batch)
await progress(done[0] / total)
return
answers = {str(a.get("item")): a for a in data.get("results") or [] if isinstance(a, dict)}
for key, item_id, _, allowed in keyed:
r = judge(answers.get(key), allowed, scope)
if r.reason_code and r.reason_code != "model_error":
stats.fail(r.reason_code)
results[item_id] = r
done[0] += len(batch)
await progress(done[0] / total)
await asyncio.gather(*(call(queue[i : i + BATCH], 0) for i in range(0, len(queue), BATCH)))
if queue and all(results[item_id].reason_code == "model_error" for item_id, _, _ in queue):
raise TransientError(
"model_error", "all check.match batches failed"
) # 一批都没成功:按基础设施错误失败,退回额度
return results, stats