"""响应匹配(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", 2) CANDIDATES = 5 # 每条清单项送给模型的候选句数 ROW_MATES = 6 # 候选句在表格里时,带上同一行的单元格(检索常命中投标人照抄要求的那一格,响应和偏离在同一行的别的格) 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 apply_modes(results: dict[UUID, Result], items: dict[UUID, dict[str, Any]], modes: dict[UUID, Any]) -> None: """按招标文件的响应方式改结论(决策 C12):默认接受(implicit)的要求没找到响应不算问题,记「按招标文件默认接受」; 实质性要求出提示级「请人工核对」,不出致命。找到了不满足的响应照常报。modes:清单项 → applicability.Decision。""" for item_id, d in modes.items(): r = results.get(item_id) if r is None or d.mode != "implicit" or r.verdict != "not_found" or d.evidence: continue # 要附证明材料的要求,默认接受也免不了 r.verdict = "implicit_review" if items[item_id].get("importance") == "substantive" else "implicit_accepted" r.evidence_status, r.reason_code = "none", None r.detail = {**r.detail, "mode": "implicit"} 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 row_mates(s: Sentence, page: list[Sentence]) -> list[Sentence]: """同一行的其他句子:同一页、上下范围重叠(和读标核对取值时找「同一行」是同一口径)。按从左到右排。""" if not s.quads: return [] top, bottom = min(q[1] for q in s.quads), max(q[3] for q in s.quads) mates = [x for x in page if x is not s and x.quads and any(q[1] < bottom - 1 and q[3] > top + 1 for q in x.quads)] return sorted(mates, key=lambda x: min(q[0] for q in x.quads))[:ROW_MATES] def build_user( batch: list[tuple[str, dict[str, Any], list[str]]], fragments: dict[str, tuple[BidDoc, Sentence]], rows: dict[str, list[str]] | None = None, ) -> str: """数据区:待核对的要求 + 投标文件片段(每句只送一次;表格里的句子后面跟着同一行的单元格)。 系统提示词声明数据区里的指令一律不执行。""" rows = rows or {} shown: set[str] = set() frag_lines: list[str] = [] for _, _, sids in batch: for sid in sids: if sid in shown: continue shown.add(sid) doc, sent = fragments[sid] frag_lines.append(f"[{sid}]({doc.label} 第{sent.page_no}页){sent.text}") mates = [m for m in rows.get(sid, []) if m not in shown] if mates: shown.update(mates) frag_lines.append(" 同一行:" + " | ".join(f"[{m}] {fragments[m][1].text}" for m in mates)) blocks = [_item_block(k, it, sids) for k, it, sids in batch] 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]]]] = [] pages: dict[tuple[UUID, int], list[Sentence]] = {} for d in docs: for s in d.sentences: pages.setdefault((d.id, s.page_no), []).append(s) rows: dict[str, list[str]] = {} # 候选句 → 同一行的单元格(短编号) sent_of = { short_sid(d, x): (d, x) for d in docs for x in d.sentences } # 片段按编号查(同一行的单元格可能是别的条目的候选句) hits_of: dict[UUID, list[str]] = {} # 每条清单项自己的候选句(同一行的单元格跟在后面显示,不单列) 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: dict[str, tuple[BidDoc, Sentence]] = {} for h in hits: doc = doc_of[h.sentence.key] sid = short_sid(doc, h.sentence) allowed[sid] = (doc, h.sentence) hits_of.setdefault(item_id, []).append(sid) mates = row_mates(h.sentence, pages[(doc.id, h.sentence.page_no)]) rows[sid] = [short_sid(doc, m) for m in mates] for m in mates: # 同一行的单元格也可以引用(响应、偏离常写在那里) allowed.setdefault(short_sid(doc, m), (doc, m)) 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)] user = build_user( [(k, item, hits_of[item_id]) for k, item_id, item, _ in keyed], sent_of, {h: rows.get(h, []) for _, item_id, _, _ in keyed for h in hits_of[item_id]}, ) 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