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:
232
server/src/bid_doctor/check/match.py
Normal file
232
server/src/bid_doctor/check/match.py
Normal 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
|
||||
Reference in New Issue
Block a user