Files
bid-doctor/server/src/bid_doctor/check/match.py
YANG JIANKUAN cb0c2fcafe feat(check): 响应方式识别(C12)和「这条要不要投标人回答」;评测集纠错
- checklist/response_mode.py:按类别(技术 / 商务)识别招标文件的响应方式(逐条响应 / 默认接受),规则优先;
  两种写法冲突按逐条,认不出按逐条并写进覆盖范围。
- check/applicability.py:无效情形(★ / 斜体下划线标注的条款除外)、日期与递交、没要求附材料的评分标准、
  无需提供证明或由采购人查询的资格,不逐条找响应,写进覆盖范围;要求附证明材料按原文判断,中标后才交的不算。
- 默认接受的要求没找到响应:一般要求记 implicit_accepted、不出问题;实质性要求记 implicit_review、提示级
  人工核对;要附证明材料的不免(迁移 0006)。
- match.py:修同一行单元格又是别的条目候选句时的 KeyError(每条清单项单独记候选句,句子从总表里查)。
- 评测集:注入器同一要求的所有表格行一起改;散文、附件、格式文件里也响应了的要求不挂漏响应类问题;
  错位响应必须是另一个参数;复核判不成立的单元不再挂;regen.py 按固定种子重新生成。

开发集 17 份:规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32
(docs/architecture/check-eval.md §4)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:25:14 +08:00

290 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""响应匹配(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