- 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>
290 lines
13 KiB
Python
290 lines
13 KiB
Python
"""响应匹配(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
|