From 4ed945c1deaedad72d4b3b34ef96ced7b9793944 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Wed, 4 Nov 2026 12:02:26 +0800 Subject: [PATCH] =?UTF-8?q?docs(evals):=20=E6=9F=A5=E6=A0=87=E8=AF=84?= =?UTF-8?q?=E6=B5=8B=E9=9B=86=E8=A7=84=E8=8C=83=20v0.2=E3=80=81=E7=95=99?= =?UTF-8?q?=E5=87=BA=E9=9B=86=E5=88=92=E5=88=86=EF=BC=9B=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=92=8C=E7=A6=BB=E7=BA=BF=E6=9F=A5=E6=A0=87=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=9B=E6=96=B0=E5=A2=9E=E5=BE=85=E5=AE=9A=E4=BA=8B=E9=A1=B9?= =?UTF-8?q?=20C12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - evals/check/GUIDELINES.md:请 Fable 审查 v0.1 后修改。响应方式按「文件 × 类别」记(逐条 / 默认接受 / 证明材料(投标时、中标后)/ 文件要在 / 平台内应答),10 份招标文件逐份核对;规矩投标文件靠构造保证 (表格脚本生成,LLM 只写散文和改写),对全部要求响应,问题只挂人工复核过的标注单元;注入分单点 / 连续块 / 全局三种粒度、带不带标志词、两档密度;留出集注入词库和 C 风格说明开工时封存;评分补只检索 基线、按单元去重、端到端召回。 - evals/check/split.json:开发集 S01 S02 S04 S05 S07 S08 S10,留出集 S03 S06 S09 + 真实国动投标文件。 - evals/check/render.py、run_check.py。 - m3-plan.md:新增待定事项 C12(每条要求怎么才算「响应了」)。 Co-Authored-By: Claude Opus 5.5 --- docs/architecture/m3-plan.md | 3 +- evals/README.md | 8 ++ evals/check/GUIDELINES.md | 246 +++++++++++++++++++++++++++++++++++ evals/check/render.py | 184 ++++++++++++++++++++++++++ evals/check/run_check.py | 143 ++++++++++++++++++++ evals/check/split.json | 5 + 6 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 evals/check/GUIDELINES.md create mode 100644 evals/check/render.py create mode 100644 evals/check/run_check.py create mode 100644 evals/check/split.json diff --git a/docs/architecture/m3-plan.md b/docs/architecture/m3-plan.md index edf05e8..e8335f1 100644 --- a/docs/architecture/m3-plan.md +++ b/docs/architecture/m3-plan.md @@ -8,7 +8,7 @@ | 步骤 | 状态 | 说明 | |---|---|---| -| 第 0 步 评测集 | 未开始 | 第 2 步写规则之前要定稿问题分类表、响应清单格式和留出集 | +| 第 0 步 评测集 | 进行中 | 规范 v0.2(`evals/check/GUIDELINES.md`,Fable 审查后修改)和留出集划分(`evals/check/split.json`)已定;渲染、离线查标脚本已写。下一步先做 S02、S04、S10 六份跑通 | | 第 1 步 链路打通 | ✅ 2026-11-04 | 见下方「第 1 步落地记录」 | | 第 2~6 步 | 未开始 | | @@ -246,6 +246,7 @@ | C9 | **新增:每条清单项的检查结论** | 新增 `check_item_result` 表(检查批次 × 清单项:结论、走的哪一层、投标侧出处、校验状态、原因码、token)。「已响应」也是 AI 结论,按红线 1、2 要能复核、能反馈;复查复用结果、覆盖范围也靠它。问题仍然只为没响应好的条目生成。需要新迁移,并同步 `data-model.md` | | C10 | **新增:扫描件投标文件** | 真实投标的资格证明、业绩合同多是盖章扫描件,OCR(S5)还没做,不处理的话会出一大片致命级「未找到」。MVP:候选分册里图片页占比超过阈值的条目,不出「未找到响应」,改出「该部分为扫描件,请人工核对」;资格类按名称回到资质库匹配;覆盖范围列出没有文字层的页码。S5 排在 M3 之后 | | C11 | **新增:检索方案** | 内存 BM25 + jieba(§2.2),改掉 `ref-bid-engine-analysis.md` §5.4 的 pg_trgm 写法 | +| C12 | **待定(2026-11-04 新增):每条要求怎么才算「响应了」** | 招标文件对响应方式的规定按类别不同(`evals/check/GUIDELINES.md` §2):逐条响应、默认接受(只列偏离)、要证明材料(分投标时和中标后)、文件要在、平台内应答,同一份文件里常有几种,S07 还自相矛盾。建议:① 读标新增「响应方式」抽取,粒度是「类别 × 方式 × 出处」,规则优先、模型兜底,条款原文写了「提供……证明材料」的按条覆盖并记投标时 / 中标后;在清单页显示、可改,作为人工确认点。② 两处写法冲突时按更严的(逐条)处理,问题说明里同时引两处原文。③ 默认接受的要求没找到响应:一般要求记「按招标文件默认接受」、不出问题,结论带出处;实质性 / ★ 要求出「提示」级「请人工核对」,不出致命;正文里找到了不满足的值照常报。④ 证明材料是中标后提供的,投标时没附不报。⑤ 识别不出响应方式时按逐条处理(宁可多报),这一组「未找到响应」标明「招标文件未写明是否要求逐条响应」,写进覆盖范围;项目上给一个「本项目只列偏离」的开关,打开后这一组降为提示 | | SK1 | 进度推送用 Valkey stream | 确认。补两处:发布用的客户端设短超时(现在 `valkey()` 没设,Valkey 挂掉时会卡住 worker,而不是只记日志);SSE 连接池设上限 | | SK2 | 集成测试连 Compose 的 PostgreSQL | 确认。补三处:CI 直接跑 `init-roles.sh`,不再单独写一份建角色的 SQL;CI 加 Gotenberg;架构 §8 改掉 testcontainers 的写法 | diff --git a/evals/README.md b/evals/README.md index 1315b67..70830e1 100644 --- a/evals/README.md +++ b/evals/README.md @@ -22,3 +22,11 @@ cd .. && evals/run_all.sh base && cd server ``` 改了规则或提示词之后,换一个运行名重跑、打分,和上一次的 `summary.md` 对比。目前的基线是 `fix3`(`docs/architecture/extract-eval.md` §7;分数怎么读、为什么不能当达标证据,见 `extract-status.md` §2);`base` 是修复前的那一轮,用的是旧切句(s3-v3),不能拿现在的标注集重新打分。正式清单里多出来、对不上标注的条目要逐条裁定(GUIDELINES §4)。 + +## 查标评测集(2026-11-04 起) + +规范见 `check/GUIDELINES.md`,留出集划分 `check/split.json`(开工前选定,不改)。 + +- `check/render.py <投标文件>`:把投标文件稿 `out/check/<投标文件>/spec.json` 渲染成 PDF,扫描件页换成图片,回填响应清单里的位置。 +- `check/run_check.py <投标文件>`:拿读标评测的清单离线跑查标的响应匹配,模型输出缓存在 `out/cache/`。 +- 生成器 `build.py`、注入 `mutate.py`、评分 `score.py` 待写。投标文件稿、PDF、响应清单都在 `out/check/`(不进 git);留出集专用的注入词库和 C 风格写作说明封存在 `out/check/_sealed/`。 diff --git a/evals/check/GUIDELINES.md b/evals/check/GUIDELINES.md new file mode 100644 index 0000000..80b0ece --- /dev/null +++ b/evals/check/GUIDELINES.md @@ -0,0 +1,246 @@ +# 查标评测集规范 v0.2(2026-11-04) + +> 查标第 0 步(`docs/architecture/m3-plan.md` §4,决策 C1)。本文件在写第一条查标规则之前定稿:响应方式、问题分类表、投标文件怎么写、响应清单格式、留出集、评分和裁定规则。 +> 读标的教训(`docs/architecture/extract-status.md`)在这里分别这样堵:AI 标注偏宽 → **响应清单在构造时就确定,挂问题的要求经人工复核**;评分照搬抽取规则 → **只对照响应清单打分,另报只检索基线**;规则照着样本调 → **按招标文件整体留出,留出一种写法和一套注入词库,开工时封存**。 +> v0.2:请 Fable 审查 v0.1 后修改(响应方式按「文件 × 类别」记、规矩投标文件靠构造保证、注入分粒度、评分补三样口径、分类表补漏项),关键事实对照招标原文核对过。 + +## 1. 总体做法 + +1. 没有真实投标文件(真实的国动投标文件只有一份,留作最终检验,见 §4),所以按 S01–S10 的真实招标文件**自己造虚构的投标文件**。 +2. **规矩的投标文件靠构造保证,不靠复核**:偏离表、响应表、报价表、资格和评分材料清单由脚本从清单生成,技术参数按 `op / value / unit` 算一个满足的值;LLM 只写散文(技术方案、投标函叙述)和按风格改写表格行(截断、同义替换、重新编号),逐行小提示,不整本生成。 +3. 投标文件对招标文件的**全部要求**响应:「全集」用读标正式清单(`evals/out/runs/fix3//items.json`),按「招标侧页码 + 片段」记,不只响应标注集的单元。 +4. 再用确定性的脚本往里**注入问题**得到马虎的投标文件。注入了什么、注在哪里,脚本同时写进**响应清单**,它就是金标准。**问题只挂在人工复核过的标注单元上**(`evals/gold/.json`,优先实质性条款和资格条件);`optional` 和复核时补的单元不挂问题。 +5. 评分只对照响应清单,不用检索、偏离表对齐这些查标自己的逻辑来判断对错。 + +## 2. 每条要求怎么才算「响应了」:响应方式 + +招标文件对投标人怎么响应的规定**按类别不同**,同一份文件里常有几种(S01–S10 逐份核对): + +| 方式 | 含义 | 招标原文的写法 | +|---|---|---| +| `explicit` 逐条响应 | 在响应表 / 偏离表里逐条写明满足与否 | 「逐条 / 逐项作出响应」「逐一逐条响应填写」 | +| `implicit` 默认接受 | 只列和要求不同的地方,没列出的视为接受 | 「否则将认为投标人接受」「未列入上表的视作完全接受」「除本表所列明的偏离外,均视作已理解和响应」「其余无偏离内容无需赘述」 | +| `evidence` 要证明材料 | 要附证明材料(检测报告、截图、承诺函、证书),有的还要在材料里标出参数 | 「提供……证明材料」;`when`:`bid`(投标文件中提供)或 `award`(中标后、签订合同时提供,投标时只要承诺或什么都不要) | +| `presence` 文件要在 | 不是逐条响应,而是某份文件必须有、必须填全 | 「必须提供,否则投标无效」的格式文件、资格证明文件、声明函 | +| `platform` 平台内应答 | 在电子投标客户端里应答,PDF 里看不到 | S04「在电子投标系统逐条应答」 | + +**10 份文件的记录**(类别 × 方式;单条原文另写了证明材料的,按条覆盖): + +| 文件 | 技术参数 | 实质性 / ★ | 商务 | 资格 | 评分项 | 备注 | +|---|---|---|---|---|---|---| +| S01 | explicit | explicit + evidence | explicit(商务要求均为实质性) | presence | evidence | 「中标后签订合同前提供」11 处(`award`),投标文件中提供 2 处 | +| S02 | implicit | 符合性条件:explicit(符合性条件响应表);部分 ★:evidence | implicit | presence | evidence | 偏离表只列「除采购需求符合性条件外」的偏离 | +| S03 | implicit(以偏离表为准) | ★ 14 条:evidence(要标注参数)+ 实质性响应条款表 | implicit | presence | evidence | ▲ 31 条也要 evidence(级别重要) | +| S04 | explicit(全部逐一逐条) | ★:explicit(实质性响应偏离表)+ evidence | explicit | presence | evidence | 另有平台内逐条应答(platform) | +| S05 | implicit(只列正偏离) | 无标注 | implicit | presence(第 1~5 项必须全部提供) | evidence | 偏离表几乎为空是正常的 | +| S06 | explicit | explicit(斜体下划线逐条) | explicit | presence | evidence | ▲ 要 evidence,未提供视为负偏离;格式写「(如有请提供)」和须知矛盾 | +| S07 | **冲突** | ★ 国产化承诺:evidence(承诺函) | **冲突** | evidence(逐一提供证明文件) | evidence | 须知「逐条标明满足与否」vs 格式注「未列入上表的视作完全接受」 | +| S08 | explicit(非实质性也逐条) | explicit + evidence | explicit | presence | ▲ 7 项:evidence | | +| S09 | explicit | explicit | explicit(实质性) | presence | evidence | 投标时要的证明材料很少 | +| S10 | explicit | explicit | explicit | presence | evidence(人员证书 + 社保) | 技术证明材料「签订合同时提供」(`award`) | + +响应清单给每条要求记下方式和招标原文依据。**问题只在这种方式要求了、投标文件却没做到时才算**:`implicit` 的要求没被复述不是问题;`evidence` + `award` 的要求投标时没附材料不是问题。 + +> 查标怎么处理响应方式是待定事项 C12(`m3-plan.md` §7)。评测集按本节记录,C12 怎么定都能评。 + +## 3. 问题分类表 + +注入的问题分两类:**要求项问题**挂在某条要求上;**规则问题**不挂要求(或挂报价类要求)。另有**干扰项**:看起来像问题、其实不是,查标不应该报。 + +### 3.1 要求项问题 + +| 编号 | 问题 | 期望的结论 / 问题类型 | 期望级别 | 变体 | 实现阶段 | +|---|---|---|---|---|---| +| D01 | 漏响应(要求 explicit,整条没写) | `unresponded` | 按要求项 | 删一行;删连续一段;整节缺失 | 第 1、3 步 | +| D02 | 投标时要的证明材料缺失(evidence + bid) | `unresponded`(证明材料) | 按要求项 | 写「见附件 5」但附件不存在;根本没提。「附件在但没标出参数」单列,不进头条指标 | 第 3 步 | +| D03 | 数值不满足 | `suspected_unmet` | 按要求项 | 低于下限;高于上限;数量不够;单位不同换算后不满足(≥2TB 写 1024GB、≤50ms 写 0.1 秒、≥10Gbps 写 1000Mbps);偏离列写「正偏离」值却更差 | 第 3 步 | +| D05 | 功能写明不支持 | `negative_deviation` | 按要求项 | 「暂不支持」;「需二次开发后支持」;「可选配」(`negative_deviation` 或 `no_value` 都算对) | 第 3 步 | +| D06 | 负偏离没有说明 | `negative_deviation` | 按要求项 | 说明栏空;「部分满足」;只写「偏离」不写方向 | 第 3 步 | +| D07 | 偏离表写无偏离,正文的值不满足 | `suspected_unmet` | 按要求项 | 技术方案正文的值;配置清单的值;彩页摘录的值。MVP 可能不查(m3-plan §2.1),评分单列 | 第 3 步 | +| D08 | 只写「满足」,没给要的具体值或材料 | `no_value` | 提示 | 单条;整张表都写「满足」 | 第 3 步 | +| D09 | 响应对象错位 | `unresponded` | 按要求项 | 写的是另一台设备的同名参数;另一个采购包;整章是另一个型号 | 第 3 步 | +| D10 | 符合性条件响应表填「未完全响应」或空着 | `negative_deviation` | 致命 | 空着;「未完全响应」;「部分响应」 | 第 3 步 | +| D11 | 资格证明材料缺失 | `unresponded` | 致命 | 目录列了、正文没有;整份缺失;给的是别的证书 | 第 3、4 步 | +| D12 | 资格证书过期(按投标截止日) | `qualification`(过期) | 致命 | 早于截止日;整本证书过期 | 第 4 步 | +| D13 | 评分项要的证明材料缺失 | `unresponded` | 重要 | 业绩合同缺;人员证书缺;社保缺 | 第 3 步 | +| D14 | 证书主体和投标人不一致 | `qualification` | 致命 | 子公司;分公司;曾用名 | 第 4 步 | +| D15 | 业绩 / 人员证明的时间窗口不符 | `qualification` / `unresponded` | 资格致命、评分重要 | 业绩合同在近三年之外;社保月份不在要求区间 | 第 4 步 | +| D16 | 授权代表的社保证明缺失(文件要求时) | `unresponded` | 致命 | 没附;附的是别人的;月份不对 | 第 3 步 | +| D17 | 多包:采购包号空着或写错 | `formality` | 重要 | 偏离表包号空;授权书包号写错;分项表包号写错 | 第 2 步 | + +(v0.1 的 D04「换算后不满足」并入 D03 当变体。) + +### 3.2 规则问题 + +| 编号 | 问题 | 期望的规则编号 | 期望级别 | 变体 | 实现阶段 | +|---|---|---|---|---|---| +| P01 | 报价超过总价限价 | `PRICE.OVER_CEILING_TOTAL` | 致命 | 超 1 元;超 1%;万元 / 元的数量级错 | 第 2 步 | +| P02 | 报价超过采购包限价 | `PRICE.OVER_CEILING_LOT` | 致命 | 同上(S10) | 第 2 步 | +| P03 | 分项或单价超上限 | `PRICE.OVER_CEILING_ITEM` | 致命 | 单项超;单价 × 数量超 | 第 2 步(依赖 C7) | +| P04 | 大写和小写金额不一致 | `PRICE.CAPITAL_MISMATCH` | 重要 | 差一位;大写多一个「拾」;小数点错 | 第 2 步 | +| P05 | 开标一览表和分项合计不一致 | `PRICE.SUM_MISMATCH` | 重要 | 漏加一项;单价 × 数量算错;四舍五入差 | 第 2 步 | +| P06 | 出现别的项目名称、编号或采购人 | `FORM.FOREIGN_PROJECT` | 重要 | 页眉留旧项目名;授权书写旧项目编号;投标函写错采购人 | 第 2 步 | +| P07 | 该填的地方没填 | `FORM.BLANK_FIELD` | 提示 | 投标人名称空;落款日期「年 月 日」;授权书受托人空 | 第 2 步 | +| P08 | 落款日期晚于投标截止日 | `FORM.DATE_AFTER_DEADLINE` | 提示 | 晚一天;年份错;月份错 | 第 2 步 | +| P09 | 投标有效期短于要求 | `FORM.VALIDITY_SHORT` | 致命 | 90 天写 60 天;写「30 日」 | 第 2 步 | +| P10 | 附加条件 | `FORM.CONDITION_ADDED` | 重要 | 「报价不含税」;「付款方式另议」;「以实际到货为准」 | 第 2 步 | +| P11 | 缺必须提供的格式文件(presence) | `FORM.MISSING_DOC` | 致命 | 缺授权委托书;缺投标函;缺符合性条件响应表 | 第 2 步 | +| P12 | 给了两个报价 | `PRICE.MULTIPLE_QUOTES` | 致命 | 两行总价;「方案一 / 方案二」;「优惠价」 | 第 2 步 | +| P13 | 分项报价表缺项、漏项,和采购需求清单对不上 | `PRICE.ITEM_MISSING` | 致命 | 漏一项;名称对不上;数量对不上 | 第 2 步 | +| P14 | 格式文件的固定文字被改动 | `FORM.FIXED_TEXT_CHANGED` | 重要 | 删承诺句;改有效期措辞;改主体 | 第 2 步 | +| P15 | 授权委托书的受托人和投标函签字人不一致 | `FORM.SIGNER_MISMATCH` | 致命 | 姓名不同;职务不同;身份证号不同 | 第 2 步 | +| P16 | 声明函填报不全 | `FORM.DECLARATION_INCOMPLETE` | 致命(本国产品声明函三项缺一;专门面向中小企业时的中小企业声明函)/ 提示(其他) | 缺一项;未逐一填报;类型选错 | 第 2 步 | +| P17 | 签章位置没有图像 | `FORM.SEAL_ABSENT` | 提示 | 投标函;授权书;开标一览表 | 第 2 步 | +| P18 | 报价低于文件写明的阈值却没附说明 | `PRICE.LOW_NO_EXPLANATION` | 重要 | 低于阈值没说明;说明不在投标文件里 | 第 2 步 | + +### 3.3 干扰项(不应报问题) + +| 编号 | 情形 | 期望 | +|---|---|---| +| T01 | 响应写在别的分册 | 已响应 | +| T02 | 要求被截断、改写、重新编号后再响应 | 已响应 | +| T03 | 只写值不复述要求(「内存:64GB DDR4」) | 已响应 | +| T04 | 单位不同但满足(≥1024GB 写 2TB) | 已响应 | +| T05 | 正偏离 | 已响应 | +| T06 | `implicit` 的要求,投标文件没有复述 | 不报问题(按 C12 的结论) | +| T07 | 正文里出现**本项目**的名称、编号、采购人 | 不报 P06 | +| T08 | 「(盖单位章)」「(签字)」括注保留、名称和日期已填 | 不报 P07 | +| T09 | 条款写「中标后 / 签订合同时提供」,投标文件只写承诺或没写 | 不报 D02 | +| T10 | 声明函里「/」划除处未填(文件写明无需填写) | 不报 P07 | +| T11 | 评分项的可选加分没响应 | 不报致命,最多提示 | +| T12 | `implicit` 文件的偏离表只有一行「无偏离」 | 不报 D01、不报缺偏离表 | +| T13 | 正文写「见附件 N」且附件存在 | 不报 D02 | + +### 3.4 注入的分布 + +真实投标人的错是成簇、相关、不声张的,均匀分布的单点问题会让分数虚高。所以: + +- **三种粒度**:单点;连续块(相邻 5~20 行一起漏、一起错);全局模式(整张表写「满足」、整章换成另一个项目或型号、整本证书过期)。响应清单记 `granularity`。 +- **有一部分不带标志词**:没有「暂不支持」这类字样,只是合理位置上的错数字、错单位、错型号。响应清单记 `worded: false`,按带不带标志词分别报召回。 +- **两档密度**:多数马虎的投标文件注入 25~40 个(覆盖各类);另选 3 份只注入 5~8 个(接近真实密度),看低密度下误报会不会淹没真问题。 +- 注入词库分**开发集用**和**留出集用**两份(§4)。 + +## 4. 样本和留出集(开工前选定,之后不改) + +| 集合 | 招标文件 | 选它的理由 | +|---|---|---| +| 开发集 | S01、S02、S04、S05、S07、S08、S10 | 逐条响应(S01、S08、S10)、默认接受(S02、S05)、全逐条 + ★ 双表 + 证明材料(S04)、冲突(S07)、中标后提供(S01、S10)、多包和单价上限(S10)、磋商(S08)、WPS 旧版 DOC(S07) | +| 留出集 | S03、S06、S09 | S03:★ / ▲ 证明材料、一般参数默认接受;S06:分项最高限价、只用斜体下划线、清单最大;S09:磋商、★ 和斜体下划线双重标注、WPS 旧版 DOC。留出集里没有 S05 那种「只列正偏离」的写法,报告里写明 | +| 留出集(真实) | 国动小程序投标文件(用户提供) | 唯一的真实投标文件,第 3 步冻结代码后要来跑一次,之前不看、不按它调。它对应的招标文件不在 S01–S10 里,届时单独走一遍读标、人工看过清单再评 | + +**写法风格**: + +| 风格 | 写法 | 用在 | +|---|---|---| +| A 复述型 | 响应表、偏离表照抄要求原文,再写响应值和「无偏离」(苏采云范本的常见填法) | 开发集 | +| B 简写型 | 要求被截断、改写、重新编号;技术方案正文只写值不复述要求;部分响应写在别的分册 | 开发集、留出集 | +| C 叙述型 | 以叙述性技术方案为主,表格少;自己的条目编号;同义词替换 | **只用在留出集** | + +**封存**:留出集专用的注入词库和 C 风格写作说明,在开工时由另一个会话写好,放进 `evals/out/check/_sealed/`,之后不打开;写它的会话不接触查标规则代码。写 C 风格的会话不看 A / B 的产出。`check.match` 提示词的例子和注入词库做文本比对,不允许出现同样的短语。写作说明里的例子不取自 S01–S10 的招标文件和开发集投标文件。 + +每份招标文件写两份投标文件:开发集是 A 规矩 + B 马虎(按文件轮换);留出集是 B 规矩 + C 马虎。一共 20 份。**先做 S02、S04、S10 这 6 份**,端到端跑通「生成 → 渲染 → 离线查标 → 评分」,修好生成器和评分脚本,再做其余开发集,留出集最后做。 + +## 5. 投标文件怎么生成 + +1. **格式用招标文件自己的**:投标函、开标一览表、分项报价表、偏离表、响应表、授权委托书、各种声明函,用这份招标文件格式章给的格式和表头。 +2. **生成流程**:`build.py` 从读标正式清单和格式章生成规矩投标文件的结构化稿(`spec.json`)→ LLM 按风格改写表格行、补写散文 → `mutate.py` 注入问题、写响应清单 → `render.py` 渲染并回填位置。 +3. **渲染**:多数走 Chromium(HTML → PDF);**至少 2~3 份走 DOCX → LibreOffice**(产品的真实路径),带合并单元格、多行单元格。资格证明、业绩合同中有一部分做成图片页,检验 C10。 +4. **自检**:冻结前用另一个模型跑一遍「找无意的偏离」,脚本扫一遍占位符和空表格,再由人抽查。 +5. **人工复核**:只复核**挂了问题的标注单元**(每份马虎的投标文件 25~40 个,20 份约 600~800 个)和各文件的响应方式记录;`ok` 的单元不复核。 + +## 6. 文件格式 + +### 6.1 投标文件稿 `spec.json` + +```json +{ + "bid": "S02-A", + "tender": "S02", + "style": "A", + "project_name": "……", + "bidder": {"name": "某某信息技术有限公司(虚构)", "credit_code": "91320000MA0000000X"}, + "volumes": [ + { + "id": "v1", + "kind": "whole", + "header": null, + "blocks": [ + {"type": "heading", "text": "一、投标函"}, + {"type": "para", "text": "……", "responds": []}, + {"type": "table", "title": "偏离表", "header": ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"], + "rows": [{"cells": ["1", "……", "……", "无偏离", ""], "responds": ["s2"], "req": {"p": 53, "t": "支持硬件BFD"}}]}, + {"type": "scan", "text": "营业执照(副本)扫描件", "lines": ["……"], "responds": ["q3"]} + ] + } + ], + "price": {"total": "1498000.00", "items": [{"name": "汇聚交换机", "qty": 3, "unit_price": "52000.00"}]} +} +``` + +`responds` 写标注单元编号(有的话);`req` 写招标侧「页码 + 片段」(全部响应块都写,不在标注集里的要求也有)。 + +### 6.2 响应清单 `manifest.json`(金标准) + +```json +{ + "bid": "S02-B-sloppy", + "tender": "S02", + "style": "B", + "split": "dev", + "kind": "sloppy", + "modes": [ + {"category": "tech_param", "mode": "implicit", "basis": [{"p": 76, "t": "否则将认为投标人接受"}]}, + {"category": "substantive", "mode": "explicit", "scope": "符合性条件", "basis": [{"p": 20, "t": "必须提供,否则投标无效"}]} + ], + "units": { + "s2": { + "mode": "evidence", "when": "bid", + "reviewed": true, + "expect": "defect", "defect": "D02", "variant": "附件不存在", + "granularity": "single", "worded": false, + "where": [{"volume": "v1", "p": 31, "t": "支持硬件BFD,最小发送间隔3.3ms"}], + "expected_location": {"volume": "v1", "section": "技术证明材料"} + }, + "s3": {"mode": "implicit", "expect": "ok", "trap": "T06", "where": []} + }, + "rule_defects": [ + {"id": "P04-1", "type": "P04", "variant": "大写多一个拾", "where": [{"volume": "v1", "p": 2, "t": "壹佰肆拾玖万捌仟元整"}]} + ] +} +``` + +- `expect`:`ok`、`defect`、`na`(不要求投标人响应,或 `platform` 平台内应答)。 +- `reviewed`:挂了问题的单元必须为 true(人工复核过要求本身和响应方式)。 +- `where` 在渲染后由脚本回填;`expected_location` 写这条响应本该在哪个分册、哪一节(整节缺失时用来核对「找过哪些分册」)。 + +## 7. 评分 + +`score.py` 读一次离线检查的输出(`run_check.py` 的 `results.json`),对照响应清单: + +1. **映射口径**:问题的任一招标侧出处和标注单元的任一 anchor 重叠即对上(复用读标评分的定位)。 +2. **按单元去重**:同一要求在清单里有几条(公告、须知、审查表),只要一条报出就算召回,几条都报只算一个对的、不算误报;几条都没报算一个漏报。 +3. **问题类型按类型族比**(`unresponded` 含证据没核对上的那种),级别单独比。 +4. 主要指标: + - **致命级问题召回率**(按分类、粒度、带不带标志词分别报;漏报比误报严重); + - **端到端召回**(读标抽到 × 查标查出),致命级单独报:对用户来说读标漏抽也是漏报;「查标召回」只在读标抽到的要求上算; + - **问题准确率**:报出的问题里对上注入问题的比例,「对上注入的问题」和「其他」分两列; + - **规矩投标文件的误报数**(每 100 条要求)、**干扰项误报率**(按 T01–T13 分别报); + - **只检索基线**:不调模型,检索第一名命中就算已响应,按风格分别报;全系统和基线差不多,说明评的是检索不是判断; + - 证据没通过校验的问题占致命问题的比例(目标 < 10%,C3);每次检查的 token 和费用(C2)。 +5. 对不上标注单元的问题单列「无法对照」,不算对也不算错。 +6. 开发集、留出集分开报;所有数字带风格标签。**头条是留出集 C 风格和国动真实投标文件**,开发集 A 风格的数字只作调试用。 + +## 8. 裁定规则 + +- 开发集上查出了响应清单里没有的问题:由人裁定一次。投标文件里确实有这个问题(生成时无意留下的),就改响应清单并记录;没有就算误报。 +- 留出集:允许改响应清单,但只限投标文件里客观可查的错;每一次记为「响应清单错误」,不算查标误报也不算查标对,单独报这个数(它大说明生成器差)。**不许据此调规则和提示词**。 +- 裁定记录写在 `evals/out/check/adjudication/.md`。 + +## 9. 放在哪里 + +| 内容 | 位置 | 进不进 git | +|---|---|---| +| 本规范、留出集划分 `split.json`、开发集写作说明和注入词库、生成 / 渲染 / 评分脚本 | `evals/check/` | 进 | +| 留出集专用的注入词库和 C 风格写作说明 | `evals/out/check/_sealed/` | 不进(封存) | +| 投标文件稿、渲染出的 PDF、响应清单、检查输出、裁定记录 | `evals/out/check/` | 不进(照着真实招标文件写的) | +| 真实的国动投标文件 | 用户本地,第 3 步冻结后提供 | 不进 | diff --git a/evals/check/render.py b/evals/check/render.py new file mode 100644 index 0000000..00f12be --- /dev/null +++ b/evals/check/render.py @@ -0,0 +1,184 @@ +"""把投标文件稿(spec.json)渲染成 PDF,并回填响应清单里的位置(evals/check/GUIDELINES.md §5、§6)。 + + cd server && ../.venv/bin/python ../evals/check/render.py <投标文件编号> [...] # 需要 Gotenberg(:3109) + +- 每个分册一份 PDF:evals/out/check//<分册编号>.pdf; +- `scan` 块单独成页,渲染后把那一页换成图片(没有文字层),用来检验扫描件的处理(C10); +- 页眉写 volume.header(默认是本项目名称;注入 P06 时改成别的项目名称); +- 渲染后用读标同一套切句找到每个响应块所在的页,写进 manifest.json 的 where(页码 + 片段)。 +""" + +from __future__ import annotations + +import html +import io +import json +import pathlib +import sys +from typing import Any + +import httpx +import pypdfium2 as pdfium + +from bid_doctor.document.pipeline import text_layer + +ROOT = pathlib.Path(__file__).resolve().parent +OUT = ROOT.parent / "out" / "check" +GOTENBERG = "http://127.0.0.1:3109/forms/chromium/convert/html" +SNIPPET = 24 # 回填位置时取块文字的前多少个字 + +CSS = """ +@page { size: A4; margin: 22mm 18mm 20mm; } +body { font-family: "Noto Serif CJK SC", "Noto Sans CJK SC", serif; font-size: 11pt; line-height: 1.75; margin: 0; } +h1 { font-size: 17pt; text-align: center; margin: 0 0 12pt; } +h2 { font-size: 14pt; margin: 14pt 0 6pt; } +h3 { font-size: 12pt; margin: 10pt 0 4pt; } +p { margin: 0 0 6pt; text-align: justify; } +table { width: 100%; border-collapse: collapse; margin: 6pt 0 10pt; font-size: 10pt; } +th, td { border: 0.6pt solid #333; padding: 3pt 4pt; vertical-align: top; } +th { background: #f2f2f2; } +.page { page-break-before: always; } +.scan { page-break-before: always; page-break-after: always; padding: 24mm 10mm; border: 1pt solid #999; min-height: 150mm; } +.scan h3 { text-align: center; } +.sign { margin-top: 18pt; text-align: right; } +""" + + +def esc(s: Any) -> str: + return html.escape(str(s if s is not None else "")) + + +def block_html(b: dict[str, Any]) -> str: + t = b.get("type") + if t == "title": + return f"

{esc(b['text'])}

" + if t == "heading": + level = int(b.get("level", 2)) + cls = ' class="page"' if b.get("new_page") else "" + return f"{esc(b['text'])}" + if t == "para": + return f"

{esc(b['text'])}

" + if t == "sign": + return "
" + "".join(f"

{esc(x)}

" for x in b["lines"]) + "
" + if t == "table": + head = "".join(f"{esc(h)}" for h in b.get("header") or []) + rows = "".join("" + "".join(f"{esc(c)}" for c in r["cells"]) + "" for r in b["rows"]) + title = f"

{esc(b['title'])}

" if b.get("title") else "" + return f"{title}{head}{rows}
" + if t == "scan": + lines = "".join(f"

{esc(x)}

" for x in b.get("lines") or []) + return f"

{esc(b['text'])}

{lines}
" + raise ValueError(f"未知的块类型:{t}") + + +def volume_html(spec: dict[str, Any], vol: dict[str, Any]) -> tuple[str, str]: + body = "".join(block_html(b) for b in vol["blocks"]) + header = esc(vol.get("header") or spec.get("project_name") or "") + # 页眉用 Gotenberg 的 header.html 传,每页都有;读标切句会把各页重复的行认成页眉页脚带, + # 查标的「别的项目名称」规则要专门扫这里(m3-plan §2.4) + return ( + f"{body}", + header, + ) + + +def render_pdf(page_html: str, header: str) -> bytes: + head = ( + "" + esc(header) + "" + ) + files = [ + ("files", ("index.html", page_html.encode(), "text/html")), + ("files", ("header.html", head.encode(), "text/html")), + ] + data = {"marginTop": "0.9", "marginBottom": "0.8", "marginLeft": "0.7", "marginRight": "0.7"} + r = httpx.post(GOTENBERG, files=files, data=data, timeout=120) + r.raise_for_status() + return r.content + + +def rasterize(pdf: bytes, pages: set[int]) -> bytes: + """把指定页(从 1 起)换成图片页:没有文字层,模拟扫描件。""" + if not pages: + return pdf + src = pdfium.PdfDocument(pdf) + out = pdfium.PdfDocument.new() + for i in range(len(src)): + if i + 1 in pages: + img = src[i].render(scale=1.6).to_pil().convert("L") + buf = io.BytesIO() + img.save(buf, format="PDF", resolution=115) + out.import_pages(pdfium.PdfDocument(buf.getvalue())) + else: + out.import_pages(src, [i]) + buf = io.BytesIO() + out.save(buf) + return buf.getvalue() + + +def page_texts(pdf: bytes) -> dict[int, str]: + pages = text_layer.extract("B", pdf) + return {p.page_no: text_layer.norm("".join(s.text for s in p.sentences)) for p in pages} + + +def block_text(b: dict[str, Any], row: dict[str, Any] | None = None) -> str: + if row is not None: + # 定位用投标人写的那一格(anchor 指定;默认第 3 格「投标文件中的内容」),不用照抄的要求原文 + cells = [str(c) for c in row["cells"]] + at = row.get("anchor", 2 if len(cells) >= 3 else len(cells) - 1) + return cells[at] + if b.get("type") == "scan": + return str(b["text"]) + return str(b.get("text") or " ".join(b.get("lines") or [])) + + +def locate(texts: dict[int, str], snippet: str) -> int | None: + q = text_layer.norm(snippet)[:SNIPPET] + for p, t in texts.items(): + if q and q in t: + return p + return None + + +def render(bid: str) -> None: + d = OUT / bid + spec = json.loads((d / "spec.json").read_text()) + manifest_path = d / "manifest.json" + manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else None + where: dict[str, list[dict[str, Any]]] = {} + problems: list[str] = [] + for vol in spec["volumes"]: + page_html, header = volume_html(spec, vol) + pdf = render_pdf(page_html, header) + texts = page_texts(pdf) + scans = {p for b in vol["blocks"] if b.get("type") == "scan" and (p := locate(texts, b["text"]))} + located: list[tuple[list[str], str]] = [] + for b in vol["blocks"]: + if b.get("type") == "table": + located += [(r.get("responds") or [], block_text(b, r)) for r in b["rows"]] + else: + located.append((b.get("responds") or [], block_text(b))) + for units, snippet in located: + if not units: + continue + page = locate(texts, snippet) + if page is None: + problems.append(f"{vol['id']}:找不到「{snippet[:SNIPPET]}」") + continue + for u in units: + where.setdefault(u, []).append( + {"volume": vol["id"], "p": page, "t": text_layer.norm(snippet)[:SNIPPET]} + ) + (d / f"{vol['id']}.pdf").write_bytes(rasterize(pdf, scans)) + print(f"{bid}/{vol['id']}.pdf {len(texts)} 页,扫描页 {sorted(scans)}") + if manifest is not None: + for uid, entry in manifest["units"].items(): + entry["where"] = where.get(uid, []) + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=1)) + for p in problems: + print(" ! " + p) + + +if __name__ == "__main__": + for b in sys.argv[1:]: + render(b) diff --git a/evals/check/run_check.py b/evals/check/run_check.py new file mode 100644 index 0000000..405218c --- /dev/null +++ b/evals/check/run_check.py @@ -0,0 +1,143 @@ +"""离线查标评测:拿读标评测的清单(evals/out/runs/<运行>/<招标文件>/items.json)对一份虚构投标文件跑响应匹配。 + +用法(在 server/ 下,模型配置读根目录 .env): + ../.venv/bin/python ../evals/check/run_check.py <投标文件编号> [--checklist fix3] [--name <运行名>] +输入:evals/out/check/<投标文件编号>/v*.pdf(render.py 渲染的)、manifest.json 里的 tender。 +输出:evals/out/check/<投标文件编号>/runs/<运行名>/results.json(每条清单项的结论、投标侧证据、生成的问题)。 +同一模型 + 同一提示词版本 + 同一输入只调用一次模型(和读标评测共用 evals/out/cache/),只改规则时重放不花钱。 +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import pathlib +import sys +from typing import Any +from uuid import UUID, uuid4 + +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.core.config import get_settings +from bid_doctor.core.db import TenantContext +from bid_doctor.document.pipeline import text_layer + +EVALS = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EVALS)) +import gold # noqa: E402 + +OUT = EVALS / "out" / "check" +CACHE = EVALS / "out" / "cache" +usage = {"calls": 0, "cached": 0, "tokens_in": 0, "tokens_out": 0} + + +async def cached_run_json( + ctx: Any, *, job_type: str, job_id: Any, task: str, prompt_id: str, version: int, user: str +) -> dict[str, Any]: + del ctx, job_type, job_id + p = prompts.get(prompt_id, version) + s = get_settings() + model = s.ai_models.get(p.capability) or s.ai_model + key = hashlib.sha256(f"{model}\n{prompt_id}\n{version}\n{p.body}\n{user}".encode()).hexdigest()[:32] + f = CACHE / f"{key}.json" + if f.exists(): + data = json.loads(f.read_text()) + usage["cached"] += 1 + else: + r = await provider().complete_json(p.body, user, p.capability, task) + data = { + "data": r.data, + "model": r.model, + "tokens_in": r.tokens_in, + "tokens_out": r.tokens_out, + "incomplete": r.incomplete, + } + f.write_text(json.dumps(data, ensure_ascii=False)) + usage["calls"] += 1 + usage["tokens_in"] += data.get("tokens_in") or 0 + usage["tokens_out"] += data.get("tokens_out") or 0 + if data.get("incomplete") or near_limit(data.get("tokens_out")): + raise OutputIncomplete(task, data["data"]) + return data["data"] + + +def checklist(tender: str, run: str) -> dict[UUID, dict[str, Any]]: + """读标评测输出的正式清单 → 查标的清单项(和检查快照的 payload 同样的字段)。""" + rows = json.loads((EVALS / "out" / "runs" / run / tender / "items.json").read_text()) + by_sid = {gold.sid(s): s for s in gold.sentences(tender)} + out: dict[UUID, dict[str, Any]] = {} + for i, r in enumerate(rows): + if r.get("zone") != "formal": + continue + text = "".join(by_sid[k].text for k in r.get("sentences") or [] if k in by_sid) + raw = (r.get("raw") or {}).get("tech_param") or {} + spec = {"tech_param": {k: raw[k] for k in ("param", "op", "value", "unit") if raw.get(k)}} if raw else {} + out[UUID(int=i + 1)] = { + "title": r.get("title"), + "requirement_text": text or None, + "categories": r.get("categories") or [], + "importance": r.get("importance"), + "importance_basis": r.get("importance_basis"), + "spec": spec, + "sentences": r.get("sentences") or [], + } + return out + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("bid") + ap.add_argument("--checklist", default="fix3") + ap.add_argument("--name", default="base") + args = ap.parse_args() + d = OUT / args.bid + manifest = json.loads((d / "manifest.json").read_text()) + items = checklist(manifest["tender"], args.checklist) + docs = [] + for n, pdf in enumerate(sorted(d.glob("v*.pdf")), start=1): + pages = text_layer.extract(f"{args.bid}-{pdf.stem}", pdf.read_bytes()) + docs.append(match.BidDoc(uuid4(), n, pdf.stem, text_layer.body_sentences(pages))) + ai_service.run_json = cached_run_json # type: ignore[assignment] + + async def progress(_: float) -> None: + return None + + ctx = TenantContext(tenant_id=UUID(int=0), user_id=UUID(int=0)) + results, stats = await match.run(ctx, uuid4(), items, docs, progress) + rows = [] + for item_id, item in items.items(): + res = results[item_id] + spec = findings.spec(res, item) + rows.append( + { + "title": item["title"], + "sentences": item["sentences"], + "categories": item["categories"], + "importance": item["importance"], + "verdict": res.verdict, + "layer": res.layer, + "evidence_status": res.evidence_status, + "reason_code": res.reason_code, + "evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in res.evidence], + "detail": res.detail, + "finding": {"type": spec.type, "severity": spec.severity, "title": spec.title} if spec else None, + } + ) + out = d / "runs" / args.name + out.mkdir(parents=True, exist_ok=True) + summary = {"items": len(rows), "usage": usage, "verify_failures": stats.verify_failures, "splits": stats.splits} + (out / "results.json").write_text(json.dumps({"summary": summary, "items": rows}, ensure_ascii=False, indent=1)) + findings_n = sum(r["finding"] is not None for r in rows) + print( + f"{args.bid}:清单 {len(rows)} 条,问题 {findings_n} 个,模型调用 {usage['calls']} 次(缓存 {usage['cached']})," + f"token {usage['tokens_in']} / {usage['tokens_out']}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/evals/check/split.json b/evals/check/split.json new file mode 100644 index 0000000..947676c --- /dev/null +++ b/evals/check/split.json @@ -0,0 +1,5 @@ +{ + "_comment": "查标评测集划分(GUIDELINES §3)。2026-11-04 开工前选定,之后不改。留出集只在第 3 步代码冻结后跑一次。", + "dev": {"tenders": ["S01", "S02", "S04", "S05", "S07", "S08", "S10"], "styles": ["A", "B"]}, + "holdout": {"tenders": ["S03", "S06", "S09"], "styles": ["B", "C"], "real": ["国动小程序投标文件(用户提供)"]} +}