From cb0c2fcafe4fc1965adb0f50dcc7fafc4950b368 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Wed, 4 Nov 2026 14:25:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(check):=20=E5=93=8D=E5=BA=94=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E8=AF=86=E5=88=AB=EF=BC=88C12=EF=BC=89=E5=92=8C?= =?UTF-8?q?=E3=80=8C=E8=BF=99=E6=9D=A1=E8=A6=81=E4=B8=8D=E8=A6=81=E6=8A=95?= =?UTF-8?q?=E6=A0=87=E4=BA=BA=E5=9B=9E=E7=AD=94=E3=80=8D=EF=BC=9B=E8=AF=84?= =?UTF-8?q?=E6=B5=8B=E9=9B=86=E7=BA=A0=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 3 +- docs/architecture/check-eval.md | 32 ++++++- evals/check/GUIDELINES.md | 2 +- evals/check/mutate.py | 75 ++++++++++++--- evals/check/regen.py | 91 +++++++++++++++++++ evals/check/run_check.py | 31 ++++++- .../versions/0006_check_verdict_implicit.py | 31 +++++++ server/src/bid_doctor/check/applicability.py | 56 ++++++++++++ server/src/bid_doctor/check/findings.py | 8 +- server/src/bid_doctor/check/match.py | 12 +++ server/src/bid_doctor/check/runner.py | 55 ++++++++++- .../src/bid_doctor/checklist/response_mode.py | 63 +++++++++++++ server/tests/test_check_units.py | 54 +++++++++++ 13 files changed, 492 insertions(+), 21 deletions(-) create mode 100644 evals/check/regen.py create mode 100644 server/migrations/versions/0006_check_verdict_implicit.py create mode 100644 server/src/bid_doctor/check/applicability.py create mode 100644 server/src/bid_doctor/checklist/response_mode.py diff --git a/AGENTS.md b/AGENTS.md index 04fa14d..6e0d5b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,4 +211,5 @@ samples/ - 2026-11-04:查标评测开发集第一版:7 份招标文件、14 份投标文件(风格 A / B 轮换),注入问题 222 个、Fable 复核通过 188 个。查标召回 40%(致命级 49%),带标志词的问题只有 22%;规矩投标文件报出 699 个问题,近一半在不需要响应的要求上(`docs/architecture/check-eval.md` §2)。 - 2026-11-04:用户抽查第二批复核 28 条,全部同意(两批合计 45 条)。 - 2026-11-04:查标第一轮改进:候选句带同一行的单元格、提示词 check.match v2。开发集 17 份上召回 30% → 50%,带标志词的问题 2% → 54%,误报略降,输入 token +44%;漏响应和响应对象错位变差(`docs/architecture/check-eval.md` §3)。评测集修了待修项、加了 3 份低密度投标文件,第三批复核 55 条、用户抽查 11 条全部同意。 -- 下一步:看 D01、D09 的漏报明细改提示词;查标第 2 步(响应方式 C12、投标侧结构识别、规则)。 +- 2026-11-04:查标第二轮:评测集纠错(漏响应、错位的「变差」多是生成器只改了一处);新增响应方式识别(C12,规则优先)和「这条要不要投标人回答」的判断。开发集 17 份上规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32;证明材料缺失几乎查不出(扫描件)。第四批复核 45 条,用户抽查 9 条全部同意(四批共 65 条)。见 `docs/architecture/check-eval.md` §4。 +- 下一步:扫描件兜底和文件要在(C10);响应方式存进清单并可改;查标第 2 步的投标侧结构识别和规则。 diff --git a/docs/architecture/check-eval.md b/docs/architecture/check-eval.md index bc0b77a..8de8bf4 100644 --- a/docs/architecture/check-eval.md +++ b/docs/architecture/check-eval.md @@ -103,8 +103,34 @@ **读法**:整行上下文解决了「看不见响应格、偏离格」的问题,带标志词的问题从几乎查不出到过半;误报略降。代价是输入 token 多 44%。**两处变差**:漏响应(D01)和响应对象错位(D09)。推测是同一行或相邻行的内容让模型更容易「找到点什么就判已响应」:漏掉的那一行不在了,检索命中的是相邻要求的行;错位的那一行响应格写的是另一条参数,模型没比对参数名。下一轮在提示词里要求先核对响应格说的是不是同一个参数 / 设备,并单独看 D01、D09 的漏报明细。不需要响应的要求上的误报(329)没有变化,要靠第 ② 项(C12)。 -## 4. 下一步 +## 4. 第二轮:评测集纠错 + 响应方式和「要不要回答」(2026-11-04) -1. 看 D01、D09 的漏报明细,改提示词(v3):先核对响应格说的是不是同一个参数 / 设备。 -2. 第 2 步(投标侧结构识别、规则、C12 响应方式抽取)和第 3 步(整行上下文、偏离表直取、presence 判断)按 §2.2 的顺序改,每改一轮在开发集 14 份上重跑对比。 +**评测集纠错**:§3 里漏响应、响应对象错位「变差」,逐条看漏报后发现多数是**生成器的错**,不是查标的错:读标清单有重复条目、S04 同一参数列在两张表里、技术方案散文也复述了要求,注入器只改了其中一处,别处还留着正确的响应,模型找到它是对的。改为同一要求的所有表格行一起改;在散文、附件、格式文件里也响应了的要求不挂漏响应 / 数值不满足 / 错位;错位响应必须是另一个参数的。马虎的投标文件按原种子重新生成,新目标 45 个由 Fable 复核(通过 40 个),用户抽查 9 个全部同意(四批合计抽查 65 个,全部同意)。 + +**查标改了什么**(第 2 步的 C12 部分): +- `checklist/response_mode.py`:按类别(技术 / 商务)识别招标文件的响应方式,规则优先;冲突按逐条,认不出按逐条并标明。开发集 7 份的识别结果和档案一致。(还没存进清单、没做清单页的显示和修改。) +- `check/applicability.py`:不需要投标人回答的条目不逐条找响应,写进覆盖范围(无效情形 —— 但 ★ / 斜体下划线标注的条款照常找、日期与递交、没要求附材料的评分标准、无需提供证明或由采购人查询的资格)。要求附证明材料按原文判断(读标的 evidence_required 常常是空的),中标后才交的不算。 +- 默认接受的要求没找到响应:一般要求记「按招标文件默认接受」、不出问题;实质性要求出提示级「请人工核对」;要附证明材料的不免(迁移 0006 加了两种结论)。 + +| 指标(17 份,复核通过的问题) | v1 | v2 + 整行 | + 响应方式和「要不要回答」 | +|---|---|---|---| +| 查标召回 | 48 / 150(32%) | 102 / 150(68%) | 97 / 150(65%) | +| 致命级召回 | 9 / 32 | 20 / 32 | **21 / 32** | +| 规矩的投标文件报出的问题 | 699 | 639 | **229** | +| 规矩响应的要求上的误报 | 24.7 / 100 | 21.3 / 100 | **12.4 / 100** | +| 不需要响应的要求上报的问题 | 334 | 329 | **77** | +| 默认接受的要求被报(T06) | 39 | 35 | **1** | +| 输入 token | 189 万 | 273 万 | 224 万 | + +按问题类型(最后一列):漏响应 19 / 24、写明不支持 13 / 14、数值不满足 4 / 4、负偏离未说明 39 / 59、评分材料缺失 9 / 14、错位 6 / 13;**证明材料缺失 1 / 17**。 + +**读法**:规矩的投标文件上的问题少了三分之二,召回只掉了 3 个点(掉的主要是只在默认接受的文件里、被正确判为「默认接受」的条目,以及证明材料)。证明材料缺失几乎查不出:资格证明、检测报告多是扫描件页,没有文字层,规矩的投标文件上同样报「未找到」——这是下一项(C10 扫描件兜底 + 文件要在的判断)。 + +**一处要说明的**:调响应方式识别时,打印结果里顺带出现了留出集 S03、S06、S09(招标文件本身,不是投标文件)的识别结果,其中 S03、S09 和规范 §2 的记录不一致。没有据此改任何规则,留到留出集正式评测时再看。 + +## 5. 下一步 + +1. 证明材料和格式文件:扫描件兜底(C10)、文件要在(presence)按文件在不在判断。 +2. 响应方式存进清单、清单页显示并可改(C12 的人工确认点)。 +3. 第 2 步(投标侧结构识别、规则、C12 响应方式抽取)和第 3 步(整行上下文、偏离表直取、presence 判断)按 §2.2 的顺序改,每改一轮在开发集 14 份上重跑对比。 3. 留出集(S03、S06、S09,风格 B + C,封存的词库)和真实国动投标文件,在第 3 步冻结代码后只跑一次。 diff --git a/evals/check/GUIDELINES.md b/evals/check/GUIDELINES.md index 8074e53..be1ce55 100644 --- a/evals/check/GUIDELINES.md +++ b/evals/check/GUIDELINES.md @@ -9,7 +9,7 @@ 1. 没有真实投标文件(真实的国动投标文件只有一份,留作最终检验,见 §4),所以按 S01–S10 的真实招标文件**自己造虚构的投标文件**。 2. **规矩的投标文件靠构造保证,不靠复核**:偏离表、响应表、报价表、资格和评分材料清单由脚本从清单生成,技术参数按 `op / value / unit` 算一个满足的值;LLM 只写散文(技术方案、投标函叙述)和按风格改写表格行(截断、同义替换、重新编号),逐行小提示,不整本生成。 3. 投标文件对招标文件的**全部要求**响应:「全集」用读标正式清单(`evals/out/runs/fix3//items.json`),按「招标侧页码 + 片段」记,不只响应标注集的单元。 -4. 再用确定性的脚本往里**注入问题**得到马虎的投标文件。注入了什么、注在哪里,脚本同时写进**响应清单**,它就是金标准。**问题只挂在人工复核过的标注单元上**(`evals/gold/.json`,优先实质性条款和资格条件);`optional` 和复核时补的单元不挂问题。 +4. 再用确定性的脚本往里**注入问题**得到马虎的投标文件。注入了什么、注在哪里,脚本同时写进**响应清单**,它就是金标准。**问题只挂在复核过的标注单元上**(`evals/gold/.json`,优先实质性条款和资格条件;复核方式见决策 E1)。`optional` 和复核时补的单元也可以挂,按内容复核(四批复核和用户抽查都是这样做的);**上一次复核判为不成立的单元不再挂问题**。 5. 评分只对照响应清单,不用检索、偏离表对齐这些查标自己的逻辑来判断对错。 ## 2. 每条要求怎么才算「响应了」:响应方式 diff --git a/evals/check/mutate.py b/evals/check/mutate.py index b15e720..2c12211 100644 --- a/evals/check/mutate.py +++ b/evals/check/mutate.py @@ -41,10 +41,18 @@ UNIT_DOWN = {"TB": ("GB", 1024), "GB": ("MB", 1024), "Gbps": ("Mbps", 1000), "s" class Mutator: def __init__(self, spec: dict[str, Any], vocab: dict[str, Any], rng: random.Random) -> None: self.spec, self.vocab, self.rng = spec, vocab, rng + self.vols = spec["volumes"] self.unit_defects: dict[str, dict[str, Any]] = {} self.rule_defects: list[dict[str, Any]] = [] self.umbrella: set[str] = set() - self.vols = spec["volumes"] + self.rejected: set[str] = set() # 以前复核判为不成立的单元(同一招标文件的任何投标文件里),不再挂问题 + # 在表格以外(技术方案散文、附件、格式文件)也响应了的单元:删表格行、改响应值都不构成问题,不挂这几类 + self.prose = [ + {t[i : i + 2] for t in [re.sub(r"\W", "", b["text"])] for i in range(len(t) - 1)} + for b in self.blocks + if b.get("type") == "para" and b.get("form") == "prose" + ] + self.elsewhere = {u for b in self.blocks if b.get("type") != "table" for u in b.get("responds") or []} @property def blocks(self) -> list[dict[str, Any]]: @@ -80,7 +88,7 @@ class Mutator: return out def free(self, units: list[str]) -> bool: - return bool(units) and not any(u in self.unit_defects for u in units) + return bool(units) and not any(u in self.unit_defects or u in self.rejected for u in units) def mark( self, units: list[str], defect: str, variant: str, granularity: str = "single", worded: bool = True @@ -103,10 +111,11 @@ class Mutator: chunk = rows[start : start + n] if not all(self.free(r.get("responds") or []) for r in chunk if r.get("responds")): continue + if any(set(r.get("responds") or []) & self.elsewhere for r in chunk): + continue for r in chunk: self.mark(r.get("responds") or [], "D01", "删连续一段", "block", worded=False) - del rows[start : start + n] - self.drop_everywhere([u for r in chunk for u in r.get("responds") or []]) + self.drop_everywhere([u for r in chunk for u in r.get("responds") or []], [(t, r) for r in chunk]) return def row_ops(self, n: int) -> None: @@ -126,10 +135,43 @@ class Mutator: done += 1 break - def drop_everywhere(self, units: list[str]) -> None: - """漏响应要在所有表里都漏:S04 的技术参数同时列在全量偏离表和 ★ 表里,只删一张表等于没漏。""" + def in_prose(self, text: str) -> bool: + """技术方案散文里基本复述了这条要求(字 2-gram 有六成以上出现在同一段里):表格里删掉或改错,散文里还在响应。""" + t = re.sub(r"\W", "", text) + grams = {t[i : i + 2] for i in range(len(t) - 1)} + return len(grams) >= 6 and any(len(grams & g) / len(grams) >= 0.6 for g in self.prose) + + def _req_text(self, t: dict[str, Any], r: dict[str, Any]) -> str: + at = self.col(t, "requirement") + return re.sub(r"\W", "", r["cells"][at]) if at is not None else "" + + def siblings(self, t: dict[str, Any], r: dict[str, Any]) -> list[tuple[dict[str, Any], dict[str, Any]]]: + """同一条要求在投标文件里的所有表格行:挂同一个标注单元,或者要求原文相同(读标清单常把同一要求抽成几条, + S04 的参数同时列在两张表里)。注入的问题要在这些行上一起做,否则别处还留着正确的响应,就不是问题了。""" + units, text = set(r.get("responds") or []), self._req_text(t, r) + out = [] + for t2 in self.tables(): + for r2 in t2["rows"]: + if r2 is r: + continue + if units & set(r2.get("responds") or []) or (len(text) >= 6 and self._req_text(t2, r2) == text): + out.append((t2, r2)) + return out + + def propagate(self, t: dict[str, Any], r: dict[str, Any]) -> None: + """把这一行改过的响应、偏离、说明照样写到同一要求的其他行上。""" + for t2, r2 in self.siblings(t, r): + for field in ("answer", "deviation", "note"): + a, b = self.col(t, field), self.col(t2, field) + if a is not None and b is not None: + r2["cells"][b] = r["cells"][a] + + def drop_everywhere(self, units: list[str], rows: list[tuple[dict[str, Any], dict[str, Any]]] = ()) -> None: + """漏响应要在所有表里都漏:S04 的技术参数同时列在全量偏离表和 ★ 表里,只删一张表等于没漏; + 要求原文相同的重复行也一起删。""" + drop = {id(r2) for t, r in rows for _, r2 in self.siblings(t, r)} | {id(r) for _, r in rows} for t in self.tables(): - t["rows"] = [r for r in t["rows"] if not set(r.get("responds") or []) & set(units)] + t["rows"] = [r for r in t["rows"] if id(r) not in drop and not set(r.get("responds") or []) & set(units)] @staticmethod def col(t: dict[str, Any], field: str) -> int | None: @@ -148,12 +190,14 @@ class Mutator: numeric = tp.get("op") in (">=", "<=", "=") and tp.get("value") if op == "D05" and (numeric or not FUNCTIONAL.search(req_text) or NON_FUNCTIONAL.search(req_text)): return False # 「暂不支持」只套在功能类要求上(数值参数、证书、清单、服务义务不会这么写) + if op in ("D01", "D03", "D08", "D09") and (set(units) & self.elsewhere or self.in_prose(req_text)): + return False if op == "D01": - t["rows"].remove(r) - self.drop_everywhere(units) + self.drop_everywhere(units, [(t, r)]) self.mark(units, "D01", "删一行", worded=False) elif op == "D03": - return self._unmet(r, tp, ans, dev) + if not self._unmet(r, tp, ans, dev): + return False elif op == "D05": param = tp.get("param") or (r["cells"][req][:12] if req is not None else "该功能") r["cells"][ans] = self.phrase("D05", "phrases").format(param=param) @@ -171,11 +215,14 @@ class Mutator: r["cells"][ans] = self.phrase("D08", "phrases") self.mark(units, "D08", r["cells"][ans], worded=False) elif op == "D09": + mine = str(tp.get("param") or "") others = [ x for _, x in cands if x is not r and (x.get("tp") or {}).get("param") + and x["tp"]["param"] != mine # 必须是另一个参数:同名参数(另一台设备的同一指标)的响应可能恰好满足 + and (not mine or mine not in x["cells"][x.get("anchor", ans)]) and x["cells"][x.get("anchor", ans)] != r["cells"][ans] ] if not others: @@ -183,6 +230,8 @@ class Mutator: o = self.rng.choice(others) r["cells"][ans] = o["cells"][o.get("anchor", ans)] self.mark(units, "D09", "另一条参数的响应", worded=False) + if op != "D01": + self.propagate(t, r) return True def _unmet(self, r: dict[str, Any], tp: dict[str, Any], ans: int, dev: int | None) -> bool: @@ -205,7 +254,7 @@ class Mutator: if dev is not None and self.rng.random() < 0.5: r["cells"][dev] = "无偏离" # 一半在偏离栏仍写「无偏离」(另一半保持原样,也是无偏离或正偏离) self.mark(r.get("responds") or [], "D03", variant, worded=False) - return True + return True # 同一要求的其他行由调用方(_apply)同步 def implicit_deviation(self, n: int) -> None: """只列偏离的表:挑默认接受的要求写成偏离(D06 负偏离未说明 / D05 写明不支持)。""" @@ -421,6 +470,10 @@ def main() -> None: optional = {u.id for u in units if u.optional} modes = unit_modes(spec) # 注入前算:被删掉的行也要知道它本来该怎么响应 m = Mutator(spec, json.loads(VOCAB[a.split].read_text()), random.Random(a.seed)) # noqa: S311 评测数据,不用于安全 + for f in OUT.glob(f"{tender}-*/manifest.json"): + for uid, e in json.loads(f.read_text())["units"].items(): + if (e.get("review") or {}).get("ok") is False: + m.rejected.add(uid) # noqa: S311 评测数据,不用于安全 if not a.manifest_only: if a.density == "high": m.d01_block() diff --git a/evals/check/regen.py b/evals/check/regen.py new file mode 100644 index 0000000..0536e1b --- /dev/null +++ b/evals/check/regen.py @@ -0,0 +1,91 @@ +"""按固定的种子重新生成开发集的马虎投标文件,沿用复核过的结论(同一招标文件里复核过的「单元 + 问题类型」), +列出没复核过的新目标(evals/out/check/review/pending-units.json)。 + + cd server && ../.venv/bin/python ../evals/check/regen.py +""" + +from __future__ import annotations + +import json +import pathlib +import subprocess +import sys + +EVALS = pathlib.Path(__file__).resolve().parents[1] +OUT = EVALS / "out" / "check" +# (规矩的投标文件,马虎的投标文件,种子,密度):开发集的固定组合(GUIDELINES §4) +SLOPPY = [ + ("S02-B", "S02-B-sloppy", 11, "high"), + ("S04-A", "S04-A-sloppy", 7, "high"), + ("S10-B", "S10-B-sloppy", 13, "high"), + ("S01-A", "S01-A-sloppy", 17, "high"), + ("S05-B", "S05-B-sloppy", 23, "high"), + ("S07-A", "S07-A-sloppy", 19, "high"), + ("S08-B", "S08-B-sloppy", 29, "high"), + ("S04-B", "S04-B-low", 31, "low"), + ("S08-A", "S08-A-low", 37, "low"), + ("S10-A", "S10-A-low", 41, "low"), +] + + +def reviews(tender: str) -> dict[tuple[str, str], dict]: + out = {} + for f in OUT.glob(f"{tender}-*/manifest.json"): + for uid, e in json.loads(f.read_text())["units"].items(): + if e.get("review") and e.get("expect") == "defect": + out[(uid, e["defect"])] = {k: v for k, v in e["review"].items() if k != "user_spot_check"} + return out + + +def main() -> None: + prior = {t: reviews(t) for t in {c[:3] for c, *_ in SLOPPY}} + pending = [] + for clean, sloppy, seed, density in SLOPPY: + tender = clean[:3] + cmd = [ + sys.executable, + str(EVALS / "check" / "mutate.py"), + clean, + sloppy, + "--split", + "dev", + "--seed", + str(seed), + "--density", + density, + ] + print(subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.strip()) + p = OUT / sloppy / "manifest.json" + m = json.loads(p.read_text()) + gold = {x["id"]: x for x in json.loads((EVALS / "gold" / f"{tender}.json").read_text())["items"]} + for uid, e in m["units"].items(): + if e.get("expect") != "defect": + continue + if e.get("defect") == "D02": + e["mode"] = "evidence" + r = prior[tender].get((uid, e["defect"])) + if r: + e["reviewed"], e["review"] = r["ok"], r + else: + x = gold[uid] + pending.append( + { + "bid": sloppy, + "tender": tender, + "unit": uid, + "cat": e["cat"], + "basis": x.get("basis"), + "anchors": x.get("anchors"), + "note": x.get("note"), + "mode": e["mode"], + "defect": e["defect"], + "variant": e.get("variant"), + } + ) + p.write_text(json.dumps(m, ensure_ascii=False, indent=1)) + (OUT / "review" / "pending-units.json").write_text(json.dumps(pending, ensure_ascii=False, indent=1)) + print("待复核的新目标:", len(pending)) + + +if __name__ == "__main__": + main() diff --git a/evals/check/run_check.py b/evals/check/run_check.py index c32b5db..0d1a984 100644 --- a/evals/check/run_check.py +++ b/evals/check/run_check.py @@ -23,7 +23,8 @@ from bid_doctor.ai import prompts from bid_doctor.ai import service as ai_service from bid_doctor.ai.providers.base import near_limit from bid_doctor.ai.service import OutputIncomplete, provider -from bid_doctor.check import findings, match +from bid_doctor.check import applicability, findings, match +from bid_doctor.checklist import response_mode from bid_doctor.core.config import get_settings from bid_doctor.core.db import TenantContext from bid_doctor.document.pipeline import text_layer @@ -120,6 +121,7 @@ async def main() -> None: ap.add_argument("--name", default="base") ap.add_argument("--baseline", action="store_true", help="只检索、不调模型的基线") ap.add_argument("--v1", action="store_true", help="第 1 步的做法:提示词 v1、不带同一行的单元格(对比基线)") + ap.add_argument("--no-modes", action="store_true", help="不按响应方式和「要不要回答」筛选(C12 之前的做法)") args = ap.parse_args() d = OUT / args.bid manifest = json.loads((d / "manifest.json").read_text()) @@ -137,9 +139,34 @@ async def main() -> None: return None ctx = TenantContext(tenant_id=UUID(int=0), user_id=UUID(int=0)) + all_items = items + decisions: dict[UUID, applicability.Decision] = {} + if not args.no_modes: + modes = response_mode.detect(gold.sentences(manifest["tender"])) + decisions = {i: applicability.decide(p, modes) for i, p in items.items()} + items = {i: p for i, p in items.items() if decisions[i].match} results, stats = await match.run(ctx, uuid4(), items, docs, progress) + if decisions: + match.apply_modes(results, items, decisions) rows = [] - for item_id, item in items.items(): + for item_id, item in all_items.items(): + if item_id not in items: + rows.append( + { + "title": item["title"], + "sentences": item["sentences"], + "categories": item["categories"], + "importance": item["importance"], + "verdict": f"skipped:{decisions[item_id].skip}", + "layer": None, + "evidence_status": "none", + "reason_code": None, + "evidence": [], + "detail": {}, + "finding": None, + } + ) + continue res = results[item_id] spec = findings.spec(res, item) rows.append( diff --git a/server/migrations/versions/0006_check_verdict_implicit.py b/server/migrations/versions/0006_check_verdict_implicit.py new file mode 100644 index 0000000..d691bce --- /dev/null +++ b/server/migrations/versions/0006_check_verdict_implicit.py @@ -0,0 +1,31 @@ +"""查标结论加「按招标文件默认接受」(决策 C12)。 + +- implicit_accepted:一般要求,不出问题; +- implicit_review:实质性要求,提示级人工核对。 + +Revision ID: 0006 +""" + +from __future__ import annotations + +from alembic import op + +revision = "0006" +down_revision = "0005" + +VERDICTS = "'responded', 'not_found', 'negative_deviation', 'suspected_unmet', 'no_value', 'undetermined', 'scanned'" + + +def upgrade() -> None: + op.get_bind().exec_driver_sql(f""" +ALTER TABLE check_item_result DROP CONSTRAINT check_item_result_verdict_check; +ALTER TABLE check_item_result ADD CONSTRAINT check_item_result_verdict_check + CHECK (verdict IN ({VERDICTS}, 'implicit_accepted', 'implicit_review')); +""") + + +def downgrade() -> None: + op.get_bind().exec_driver_sql(f""" +ALTER TABLE check_item_result DROP CONSTRAINT check_item_result_verdict_check; +ALTER TABLE check_item_result ADD CONSTRAINT check_item_result_verdict_check CHECK (verdict IN ({VERDICTS})); +""") diff --git a/server/src/bid_doctor/check/applicability.py b/server/src/bid_doctor/check/applicability.py new file mode 100644 index 0000000..e4d4147 --- /dev/null +++ b/server/src/bid_doctor/check/applicability.py @@ -0,0 +1,56 @@ +"""每条清单项要不要在投标文件里找响应、按什么响应方式判(决策 C12,`docs/architecture/check-eval.md` §2.2 第 ② 项)。 + +开发集上规矩的投标文件报出的问题,近一半落在不需要投标人回答的条目上(无效情形的列举、日期、评分标准、 +采购人自己查询的资格)。这些条目不逐条找响应,写进检查覆盖范围: +- 无效情形、评审规定(只有「无效」类别、不是标注出来的条款):由规则检查(第 2 步)和人工核对覆盖; +- 日期与递交:由规则检查(落款日期、有效期)和需人工核对覆盖; +- 评分标准本身:没写要附什么材料的不找;写了的按证明材料找; +- 资格条件里写明「无需提供」或由采购人查询(信用中国)的不找。 +其余按类别取招标文件的响应方式:技术参数用技术类,其余用商务类。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from bid_doctor.checklist.response_mode import Mode + +# 要求投标时附证明材料(读标的 evidence_required 常常是空的,按原文判断);中标后 / 签合同时才交的不算 +EVIDENCE = re.compile(r"(提供|附|出具)[^。;]{0,20}(证明|报告|截图|复印件|扫描件|证书|合同|承诺|声明|社保|发票)") +AWARD = re.compile(r"(中标后|成交后|签订合同(前|时)|合同签订(前|时))") +NO_PROOF = re.compile(r"无需提供|不需提供|无须提供|采购人[^。;]{0,12}(查询|核查|查验)|信用中国|信用记录") +SKIP_LABELS = { + "condition": "无效情形和评审规定", + "schedule": "日期与递交要求", + "scoring": "评分标准(没要求附证明材料)", + "no_proof": "资格条件(写明无需提供证明,或由采购人查询)", +} + + +@dataclass(frozen=True, slots=True) +class Decision: + match: bool + mode: str = "explicit" # explicit / implicit + skip: str | None = None # 不找响应的原因(SKIP_LABELS 的键) + mode_status: str = "found" + evidence: bool = False # 要求投标时附证明材料:默认接受也不能免(没附就是没附) + + +def decide(item: dict[str, Any], modes: dict[str, Mode]) -> Decision: + cats = set(item.get("categories") or []) + text = f"{item.get('title') or ''}{item.get('requirement_text') or ''}" + if cats == {"invalidity"} and item.get("importance_basis") != "marker": + return Decision(False, skip="condition") # 「……的,投标无效」这类情形;★ / 斜体下划线标注的条款是要求,照常找 + if "schedule" in cats and not cats & {"tech_param", "qualification", "scoring"}: + return Decision(False, skip="schedule") + evidence = bool(item.get("evidence_required") or EVIDENCE.search(text)) and not AWARD.search(text) + if cats <= {"scoring"} and not evidence: + return Decision(False, skip="scoring") + if "qualification" in cats and "tech_param" not in cats and NO_PROOF.search(text): + return Decision(False, skip="no_proof") + mode = modes.get("technical" if "tech_param" in cats else "commercial") + if mode is None: + return Decision(True, evidence=evidence) + return Decision(True, mode=mode.mode, mode_status=mode.status, evidence=evidence) diff --git a/server/src/bid_doctor/check/findings.py b/server/src/bid_doctor/check/findings.py index 18a17fd..ffa5a0d 100644 --- a/server/src/bid_doctor/check/findings.py +++ b/server/src/bid_doctor/check/findings.py @@ -52,8 +52,14 @@ def spec(result: Result, item: dict[str, Any]) -> Spec | None: name = str(item.get("title") or "") base = {"importance_basis": item.get("importance_basis"), **result.detail} scope = "、".join(result.detail.get("search_scope") or []) - if result.verdict == "responded": + if result.verdict in ("responded", "implicit_accepted"): return None + if result.verdict == "implicit_review": + text = ( + "招标文件规定未列入偏离表的视为接受,投标文件里没有找到对这条要求的明确响应。" + "这是实质性要求,请人工核对所投内容确实满足。" + ) + return Spec("manual_review", "hint", f"请人工核对:{name}", text, {**base, "mode": "implicit"}) if result.verdict == "not_found" and result.evidence_status == "unverified": claimed = result.detail.get("claimed_pages") or [] where = f"{_pages(claimed)}有响应" if claimed else "有响应" diff --git a/server/src/bid_doctor/check/match.py b/server/src/bid_doctor/check/match.py index 43b498e..77f4006 100644 --- a/server/src/bid_doctor/check/match.py +++ b/server/src/bid_doctor/check/match.py @@ -74,6 +74,18 @@ class Stats: 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}" diff --git a/server/src/bid_doctor/check/runner.py b/server/src/bid_doctor/check/runner.py index 98bed47..e3139d5 100644 --- a/server/src/bid_doctor/check/runner.py +++ b/server/src/bid_doctor/check/runner.py @@ -20,16 +20,18 @@ from sqlalchemy.ext.asyncio import AsyncSession from bid_doctor.ai.models import AiCall from bid_doctor.ai.providers.base import TransientError from bid_doctor.billing import service as billing -from bid_doctor.check import findings, match +from bid_doctor.check import applicability, findings, match from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation from bid_doctor.check.service import RULE_SET_VERSION, open_fatal from bid_doctor.checklist import edit as checklist_edit +from bid_doctor.checklist import response_mode from bid_doctor.checklist import service as checklist from bid_doctor.core import events from bid_doctor.core.db import TenantContext, tenant_tx from bid_doctor.core.errors import AppError from bid_doctor.core.ids import uuid7 from bid_doctor.document import citations +from bid_doctor.document import service as documents from bid_doctor.document.citations import document_label from bid_doctor.document.models import SourceDocument from bid_doctor.document.pipeline import normalize, text_layer @@ -136,6 +138,23 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None: snapshot = await checklist_edit.version_items(s, r.checklist_version_id) lots = await projects.lot_labels(s, r.project_id) pending = (await checklist.pending_counts(s, [r.project_id])).get(r.project_id, 0) + tenders = ( + await s.execute( + select(SourceDocument).where( + SourceDocument.project_id == r.project_id, + SourceDocument.role.in_(("tender", "addendum", "clarification")), + SourceDocument.text_layer_key.is_not(None), + ) + ) + ).scalars() + tender_sents = [ + x + for d in tenders + for x in text_layer.body_sentences( + [text_layer.Page.from_json(pg) for pg in await documents.text_layer(s, ctx, d)] + ) + ] + modes = response_mode.detect(tender_sents) # 招标文件按类别的响应方式(C12) bidding = set(r.lot_ids) items, other_lot = {}, 0 for item_id, payload in snapshot.items(): @@ -144,6 +163,12 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None: items[item_id] = payload else: other_lot += 1 # 不在所投包里的条目不查,写进覆盖范围(C6) + decisions = {i: applicability.decide(p, modes) for i, p in items.items()} + skipped: dict[str, int] = {} + for d in decisions.values(): + if not d.match and d.skip: + skipped[d.skip] = skipped.get(d.skip, 0) + 1 + items = {i: p for i, p in items.items() if decisions[i].match} # 不需要投标人回答的条目不逐条找响应 last = [PROGRESS["match"]] async def progress(ratio: float) -> None: @@ -153,6 +178,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None: await _stage(ctx, run_id, "match", pct) results, stats = await match.run(ctx, run_id, items, bid_docs, progress) + match.apply_modes(results, items, decisions) await _stage(ctx, run_id, "rules") # 第 2 步:报价形式、形式要件、需人工核对 await _stage(ctx, run_id, "verify") # 前两道校验在匹配时逐条做完;这里只是进度节点 @@ -174,7 +200,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None: ) ).one() r.counts = await _counts(s, run_id) - r.coverage = _coverage(r, pages_info, items, results, pending, other_lot, stats, tokens) + r.coverage = _coverage(r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes) await _publish(ctx, r, "running", "summarize", PROGRESS["summarize"]) @@ -352,6 +378,8 @@ def _coverage( other_lot: int, stats: match.Stats, tokens: Any, + skipped: dict[str, int], + modes: dict[str, response_mode.Mode], ) -> dict[str, Any]: """检查覆盖范围:报告首页、查标页顶部都读这里(FR-M3-09、m3-plan §2.5)。""" undetermined = sum(x.verdict == "undetermined" for x in results.values()) @@ -367,6 +395,19 @@ def _coverage( not_checked.append(f"{undetermined} 条没有完成判断(模型漏答或暂时不可用),已列为「请人工核对」") if scanned: not_checked.append(f"{scanned} 页没有文字层(扫描件),这些页上的内容没有检查") + for reason, n in skipped.items(): + not_checked.append(f"{applicability.SKIP_LABELS[reason]} {n} 条:不在投标文件里逐条找响应") + labels = {"technical": "技术要求", "commercial": "商务要求"} + for m in modes.values(): + if m.status == "unknown": + not_checked.append(f"招标文件没有写明{labels[m.category]}是否要求逐条响应,按逐条响应处理") + elif m.status == "conflict": + not_checked.append( + f"招标文件对{labels[m.category]}的响应方式写法不一致(既要求逐条响应,又写明未列出的视为接受),按逐条响应处理" + ) + implicit = sum(x.verdict in ("implicit_accepted", "implicit_review") for x in results.values()) + if implicit: + checked.append(f"其中 {implicit} 条按招标文件「未列入偏离的视为接受」处理,没有复述不算问题") not_checked.append("报价形式、形式要件、资质匹配:规则检查还没有上线") if not r.checklist_confirmed: not_checked.append("清单未确认,检查结果以当前清单为准") @@ -383,6 +424,16 @@ def _coverage( "evidence_unverified": unverified, }, "checklist_confirmed": r.checklist_confirmed, + "skipped": skipped, + "response_modes": [ + { + "category": m.category, + "mode": m.mode, + "status": m.status, + "basis": [{"page": x.page_no, "text": x.text[:120]} for x in (m.implicit + m.explicit)[:4]], + } + for m in modes.values() + ], "legal_regime": {"value": r.legal_regime, "rules_verified": r.legal_regime in REGIMES_VERIFIED}, "rule_set_version": RULE_SET_VERSION, "prompt_versions": r.prompt_versions, diff --git a/server/src/bid_doctor/checklist/response_mode.py b/server/src/bid_doctor/checklist/response_mode.py new file mode 100644 index 0000000..52b0cd2 --- /dev/null +++ b/server/src/bid_doctor/checklist/response_mode.py @@ -0,0 +1,63 @@ +"""招标文件对投标人怎么响应的规定(决策 C12,`evals/check/GUIDELINES.md` §2):按类别(技术 / 商务)识别响应方式。 + +- explicit 逐条响应:「逐条 / 逐项作出响应」「逐一逐条响应填写」「逐条标明满足与否」; +- implicit 默认接受:「否则将认为投标人接受」「未列入上表的视作完全接受」「除所列偏离外均视作理解和响应」 + 「其余无偏离内容无需赘述」; +- 两种写法都有(S07)按逐条处理,记 conflict,问题说明里引两处原文;一种都没有按逐条处理,记 unknown。 +规则优先、纯函数;结果带原文句子,查标写进检查覆盖范围。 +TODO(C12):存进清单、在清单页显示并可改(人工确认点);模型兜底规则没认出的写法。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from bid_doctor.document.pipeline.text_layer import Sentence + +EXPLICIT = re.compile(r"逐(条|项|一)[^。;]{0,15}(响应|应答|标明|填写|填列|作出|做出|说明)") +IMPLICIT = re.compile( + r"(否则|未列入|未列出|未列明|除[^。;]{0,30}外)[^。;]{0,25}(视为|视作|认为)[^。;]{0,15}(接受|响应|理解)" + r"|无需赘述" +) +TABLE_HINT = re.compile(r"(技术|商务)[^。;]{0,12}(偏离|响应)") +LOOKBACK = 12 # 往前找几句,看这句说明属于哪张表(技术 / 商务) +CATEGORIES = ("technical", "commercial") + + +@dataclass(slots=True) +class Mode: + category: str # technical / commercial + mode: str # explicit / implicit + status: str # found / conflict / unknown + explicit: list[Sentence] = field(default_factory=list[Sentence]) + implicit: list[Sentence] = field(default_factory=list[Sentence]) + + +def _scope(i: int, sentences: list[Sentence]) -> set[str]: + """这句说明管哪一类:句子自己写了「技术 / 商务」就按它,否则看前面几句里最近的表名;都没有就两类都管。""" + own = {c for c, k in (("technical", "技术"), ("commercial", "商务")) if k in sentences[i].text} + if own: + return own + for s in reversed(sentences[max(0, i - LOOKBACK) : i]): + if m := TABLE_HINT.search(s.text): + return {"technical" if m.group(1) == "技术" else "commercial"} + return set(CATEGORIES) + + +def detect(sentences: list[Sentence]) -> dict[str, Mode]: + hits: dict[str, Mode] = {c: Mode(c, "explicit", "unknown") for c in CATEGORIES} + for i, s in enumerate(sentences): + exp, imp = bool(EXPLICIT.search(s.text)), bool(IMPLICIT.search(s.text)) + if not exp and not imp: + continue + for c in _scope(i, sentences): + (hits[c].implicit if imp else hits[c].explicit).append(s) + for m in hits.values(): + if m.explicit and m.implicit: + m.mode, m.status = "explicit", "conflict" + elif m.implicit: + m.mode, m.status = "implicit", "found" + elif m.explicit: + m.mode, m.status = "explicit", "found" + return hits diff --git a/server/tests/test_check_units.py b/server/tests/test_check_units.py index 55d5c32..4247743 100644 --- a/server/tests/test_check_units.py +++ b/server/tests/test_check_units.py @@ -77,3 +77,57 @@ def test_no_value_is_a_hint_and_unverified_is_explained() -> None: ) assert uv is not None and uv.severity == "fatal" and "第 3 页" in uv.explanation and "没能核对" in uv.explanation assert findings.spec(match.Result("responded", "model", "verified"), item) is None + + +def test_response_mode_by_category() -> None: + from bid_doctor.checklist import response_mode + + sents = [ + sent(1, "技术规格偏离表"), + sent(2, "投标人应将所有偏离的条目列入上表,未列入上表的视作投标人完全接受。"), + sent(3, "商务条款偏离表"), + sent(4, "投标人须对商务要求逐条作出响应。"), + ] + m = response_mode.detect(sents) + assert (m["technical"].mode, m["technical"].status) == ("implicit", "found") + assert (m["commercial"].mode, m["commercial"].status) == ("explicit", "found") + both = response_mode.detect([sent(1, "须逐项作出响应。"), sent(2, "否则将认为投标人接受招标文件的要求。")]) + assert (both["technical"].mode, both["technical"].status) == ("explicit", "conflict") # 写法冲突按逐条(C12) + none = response_mode.detect([sent(1, "本项目为货物采购。")]) + assert (none["commercial"].mode, none["commercial"].status) == ("explicit", "unknown") + + +def test_applicability_skips_items_that_need_no_response() -> None: + from bid_doctor.check import applicability + from bid_doctor.checklist import response_mode + + modes = response_mode.detect([sent(1, "未列入偏离表的技术要求视作投标人完全接受。")]) + decide = applicability.decide + assert decide({"categories": ["invalidity"], "importance_basis": "tender_text"}, modes).skip == "condition" + assert decide({"categories": ["invalidity"], "importance_basis": "marker"}, modes).match # ★ 条款是要求 + assert decide({"categories": ["schedule"]}, modes).skip == "schedule" + assert decide({"categories": ["scoring"]}, modes).skip == "scoring" + assert decide({"categories": ["scoring"], "evidence_required": "业绩合同"}, modes).match + assert decide({"categories": ["scoring"], "title": "业绩(2 分):提供近三年类似项目合同复印件"}, modes).match + assert ( + decide({"categories": ["scoring"], "title": "提供检测报告,中标后签订合同前提供原件"}, modes).skip == "scoring" + ) + assert decide({"categories": ["tech_param"], "title": "支持硬件BFD,提供官网截图证明"}, modes).evidence + assert decide({"categories": ["qualification"], "title": "未被列入信用中国失信名单"}, modes).skip == "no_proof" + d = decide({"categories": ["tech_param"], "title": "内存"}, modes) + assert d.match and d.mode == "implicit" + + +def test_implicit_not_found_is_accepted_but_substantive_gets_a_hint() -> None: + from uuid import uuid4 + + from bid_doctor.check import applicability + + a, b = uuid4(), uuid4() + results = {a: match.Result("not_found", "model"), b: match.Result("not_found", "model")} + items = {a: {"importance": "normal"}, b: {"importance": "substantive", "title": "★双电源"}} + decisions = {a: applicability.Decision(True, mode="implicit"), b: applicability.Decision(True, mode="implicit")} + match.apply_modes(results, items, decisions) + assert results[a].verdict == "implicit_accepted" and findings.spec(results[a], items[a]) is None + spec = findings.spec(results[b], items[b]) + assert results[b].verdict == "implicit_review" and spec is not None and spec.severity == "hint"