docs(evals): S01–S10 标注集 v1、评测工具和评测报告
- evals/gold:1,887 个标注单元(AI 标注、逐条裁定,未经人工复核),规范见 GUIDELINES.md - prepare / run_all / score / gold:导出文字层、批量抽取、打分、标注定位 - docs/architecture/extract-eval.md:修复前后的结果(三项目标全部达标)和剩余问题 - decisions.md 记 M1(只用 Flash)和 Q1–Q4;架构 §13 SK13;AGENTS.md 进度 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -200,4 +200,7 @@ samples/
|
|||||||
- 2026-11-03:读标可以走完一份文件:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除恢复、人工新增、修改记录,确认清单生成不可变版本;右侧 pdf.js 原文查看器点出处跳页、按坐标框高亮原句,选句补出处。实现约定见 `docs/architecture/api-design.md` §10。
|
- 2026-11-03:读标可以走完一份文件:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除恢复、人工新增、修改记录,确认清单生成不可变版本;右侧 pdf.js 原文查看器点出处跳页、按坐标框高亮原句,选句补出处。实现约定见 `docs/architecture/api-design.md` §10。
|
||||||
- 2026-11-03:审计待确认区:多数是好结果被校验误拦,根源在输入(表格框线被认成下划线)、提示词(要求逐项拆开)和模型没守规则。规则只修真正的缺陷,不迁就模型的错误输出;提示词出新版本(引文复制整句、并列不拆、跨页只引上一行,除实质性条款外输入改成纯文本)。国动的待确认 46 → 4,S02 17 → 13,S06 174 → 57。见架构 §13 SK11;离线评测工具 `evals/run_extract.py`。
|
- 2026-11-03:审计待确认区:多数是好结果被校验误拦,根源在输入(表格框线被认成下划线)、提示词(要求逐项拆开)和模型没守规则。规则只修真正的缺陷,不迁就模型的错误输出;提示词出新版本(引文复制整句、并列不拆、跨页只引上一行,除实质性条款外输入改成纯文本)。国动的待确认 46 → 4,S02 17 → 13,S06 174 → 57。见架构 §13 SK11;离线评测工具 `evals/run_extract.py`。
|
||||||
- 2026-11-03:请 Fable 做顾问深读参考项目的解析流水线(`docs/architecture/ref-bid-engine-analysis.md` §5),采纳五项:待确认记原因码、分块重叠和截断后对半重跑、项目基本信息逐项表态、占位符不算值、提示词规则对应原因码。发现并修复了模型输出被截断时整块条目悄悄丢失的问题。见架构 §13 SK12。
|
- 2026-11-03:请 Fable 做顾问深读参考项目的解析流水线(`docs/architecture/ref-bid-engine-analysis.md` §5),采纳五项:待确认记原因码、分块重叠和截断后对半重跑、项目基本信息逐项表态、占位符不算值、提示词规则对应原因码。发现并修复了模型输出被截断时整块条目悄悄丢失的问题。见架构 §13 SK12。
|
||||||
- 下一步:在 S01–S10 上做标注集评测(各类召回率、准确率),据此定模型选型;之后是查标(M3)。评测后要做:表格按单元格切句(两栏表格、竖排窄列文字串行)。
|
- 2026-11-03:抽取模型定为只用 DeepSeek Flash,不再对比 Pro 和思考模式,效果靠规则和提示词优化(`docs/decisions.md` M1)。
|
||||||
|
- 2026-11-03:S01–S10 标注集评测 v1(`docs/architecture/extract-eval.md`,工具和标注集在 `evals/`)。1,829 个标注单元由 AI 标注、逐条裁定,未经人工复核。标注类实质性条款召回 99.7% 达标;写明无效类召回 85.4%、实质性条款准确率 87.8%、资格条件准确率 38.4% 不达标,主因是规则缺陷(合并时待确认条目被并进正式条目、表后「上述……否则无效」说明认不出、模型提前收尾的静默丢失),修复清单 F1–F8 见该文 §4。每份文件实测约 1.4 元。还发现产品转换流程缺 WPS 旧版 DOC 预处理(S07 正文全是乱字)。
|
||||||
|
- 2026-11-03:按评测修复 F1–F8,口径 Q1–Q4 按建议确认(`docs/decisions.md`);请 Fable 当顾问审读修复,改了它指出的 7 个问题。修复后三项目标全部达标:标注类召回 99.4%、写明无效类召回 98.1%(含待确认区)、实质性条款准确率 98.4%;资格条件准确率 38% → 90%,每份文件约 1.2 元。还剩的问题见 `docs/architecture/extract-eval.md` §7.4。
|
||||||
|
- 下一步:查标(M3)。标注集还没人工复核,建议先抽查实质性条款(S03、S06)。
|
||||||
|
|||||||
@@ -860,9 +860,10 @@ S1–S4 风险最高,建议先做;S8(桌面外壳 PoC)等桌面版立项
|
|||||||
| SK10 | 文字层 v2 与结构识别 | 页眉页脚带(页码、各页重复的行)单独成句,不并进正文;标记写到页尾的句子,和下一页首句接续成一条要求(出处两页各一条);最小结构识别:十进制分级编号和冒号引出的列表找上级条款,上级条款写明无效情形的,下面的条目照样认定。文字层 v1、v2 的句子编号不同,规范化 PDF 不变 | 已实现 |
|
| SK10 | 文字层 v2 与结构识别 | 页眉页脚带(页码、各页重复的行)单独成句,不并进正文;标记写到页尾的句子,和下一页首句接续成一条要求(出处两页各一条);最小结构识别:十进制分级编号和冒号引出的列表找上级条款,上级条款写明无效情形的,下面的条目照样认定。文字层 v1、v2 的句子编号不同,规范化 PDF 不变 | 已实现 |
|
||||||
| SK11 | 待确认审计后的修正(2026-11-03) | 国动样本 73 条待确认里多数是好结果被校验误拦,逐条审计后按「规则只修真正的缺陷、不迁就模型的错误输出;模型输出不合理的改提示词」处理。**文字层 v3**:两端接竖线的横线是表格框线,不算下划线(功能表整格被误认成斜体下划线条款);句号后紧跟的右括号、右引号并进同一句(切句 s3-v3,句子编号和 v2 不同)。**结构识别**:中文编号小节(「三、……否则投标无效」)下的 1. 2.,以冒号结尾的编号条目下的句子;依据核对沿上级链往上找。**校验**:比对引文前去掉我们自己加进输入的格式标记和〔…〕附注;不足 8 个字的短句整句复制时句末标点可省;取值核对认带量词的中文数字、同一行的单元格和评分因素标题;补「投标(响应)无效」「不能通过资格审查」「没有实质性响应」「拒收」等写法。引文最短 8 个字、编号必须对、证据要全部通过,这些不放宽。**提示词**:substantive v3、qualification / scoring / schedule / tech_param v2,统一证据规则(引文复制整句、并列多项不拆、跨页只引上一行、数字写阿拉伯数字),除实质性条款外输入改成纯文本;资格不收附件范本和价格扣除条件,日期不收合同条款,技术参数一句一条。**模型路由**:`BD_AI_MODELS` 按提示词声明的能力选模型,没配的用 `BD_AI_MODEL`。离线评测工具 `evals/run_extract.py` | 已实现 |
|
| SK11 | 待确认审计后的修正(2026-11-03) | 国动样本 73 条待确认里多数是好结果被校验误拦,逐条审计后按「规则只修真正的缺陷、不迁就模型的错误输出;模型输出不合理的改提示词」处理。**文字层 v3**:两端接竖线的横线是表格框线,不算下划线(功能表整格被误认成斜体下划线条款);句号后紧跟的右括号、右引号并进同一句(切句 s3-v3,句子编号和 v2 不同)。**结构识别**:中文编号小节(「三、……否则投标无效」)下的 1. 2.,以冒号结尾的编号条目下的句子;依据核对沿上级链往上找。**校验**:比对引文前去掉我们自己加进输入的格式标记和〔…〕附注;不足 8 个字的短句整句复制时句末标点可省;取值核对认带量词的中文数字、同一行的单元格和评分因素标题;补「投标(响应)无效」「不能通过资格审查」「没有实质性响应」「拒收」等写法。引文最短 8 个字、编号必须对、证据要全部通过,这些不放宽。**提示词**:substantive v3、qualification / scoring / schedule / tech_param v2,统一证据规则(引文复制整句、并列多项不拆、跨页只引上一行、数字写阿拉伯数字),除实质性条款外输入改成纯文本;资格不收附件范本和价格扣除条件,日期不收合同条款,技术参数一句一条。**模型路由**:`BD_AI_MODELS` 按提示词声明的能力选模型,没配的用 `BD_AI_MODEL`。离线评测工具 `evals/run_extract.py` | 已实现 |
|
||||||
| SK12 | 参考 BidEngine 的五项改进(2026-11-03) | 见 `ref-bid-engine-analysis.md` §5。**原因码**:没通过校验的结果记具体原因(`extract.FAILURES`,存在清单项 `ai_values.failures`),解析结果按原因计数(`verify_failures`),提示词规则和原因码的对照表在 `prompts/README.md`。**分块**:相邻分块重叠约 800 字;模型输出被截断(`finish_reason=length`)或不是合法 JSON 时,把分块对半重跑,最多 3 层,拆到底仍不完整的记 `output_incomplete`(以前当成「这一块没有结果」,审计发现 deepseek-flash 有输出正好停在 8192 token、整块条目悄悄丢掉的情况)。**项目基本信息**:提示词 v2,12 个字段每个都要表态 found / ambiguous / not_found,跨分块合并后分 found / absent(文件本身没写)/ needs_review(抽取有问题),记在解析结果 `info_fields`。**占位符**:「____元」「××」「( )」「待填」不算取值。**提示词**:substantive v4,qualification / scoring / schedule / tech_param v3,每条禁止事项对应一个原因码。三份样本的待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55(S06 有 4 次对半重跑,截断的分块都救回来了) | 已实现 |
|
| SK12 | 参考 BidEngine 的五项改进(2026-11-03) | 见 `ref-bid-engine-analysis.md` §5。**原因码**:没通过校验的结果记具体原因(`extract.FAILURES`,存在清单项 `ai_values.failures`),解析结果按原因计数(`verify_failures`),提示词规则和原因码的对照表在 `prompts/README.md`。**分块**:相邻分块重叠约 800 字;模型输出被截断(`finish_reason=length`)或不是合法 JSON 时,把分块对半重跑,最多 3 层,拆到底仍不完整的记 `output_incomplete`(以前当成「这一块没有结果」,审计发现 deepseek-flash 有输出正好停在 8192 token、整块条目悄悄丢掉的情况)。**项目基本信息**:提示词 v2,12 个字段每个都要表态 found / ambiguous / not_found,跨分块合并后分 found / absent(文件本身没写)/ needs_review(抽取有问题),记在解析结果 `info_fields`。**占位符**:「____元」「××」「( )」「待填」不算取值。**提示词**:substantive v4,qualification / scoring / schedule / tech_param v3,每条禁止事项对应一个原因码。三份样本的待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55(S06 有 4 次对半重跑,截断的分块都救回来了) | 已实现 |
|
||||||
|
| SK13 | S01–S10 标注集评测后的修正(2026-11-03) | 见 `extract-eval.md` §7。规则:合并时待确认条目不再「借道」正式条目(`pending_categories`),表后说明、章识别、★ 节标题、输出接近上限按截断处理、格式范本和整表概括句不收;提示词 substantive v5、qualification v4、schedule v4、tech_param v4;WPS 旧版 DOC 直接修正字体表字符集;表格按单元格切句(切句 s3-v4)。口径 Q1–Q4 见 decisions.md。三项目标(标注类召回 ≥ 99%、写明无效类召回 ≥ 90%、实质性条款准确率 ≥ 95%)全部达标 | 已实现 |
|
||||||
|
|
||||||
SK11 修正前后(同一模型 deepseek-chat,即 deepseek-flash 不思考模式)的待确认数:国动 46 → 4,S02 17 → 13,S06 174 → 57;同一句被拆成多条的情况没有了。剩下的待确认多数是表格两栏或竖排文字串行(要按单元格切句才能解决)。实质性条款任务上比较了 flash 不思考、flash 思考(deepseek-reasoner)、deepseek-v4-pro:抽到的条款大体一致、互有多少,后两者慢 15~45 倍、输出 token 多 7~12 倍,没有看出明显更准;选型等标注集评测出结果再定。
|
SK11 修正前后(同一模型 deepseek-chat,即 deepseek-flash 不思考模式)的待确认数:国动 46 → 4,S02 17 → 13,S06 174 → 57;同一句被拆成多条的情况没有了。剩下的待确认多数是表格两栏或竖排文字串行(要按单元格切句才能解决)。实质性条款任务上比较了 flash 不思考、flash 思考(deepseek-reasoner)、deepseek-v4-pro:抽到的条款大体一致、互有多少,后两者慢 15~45 倍、输出 token 多 7~12 倍,没有看出明显更准;选型等标注集评测出结果再定。
|
||||||
|
|
||||||
用国动便民小程序(71 页)、S02、S06 三份文件实测(真实模型 DeepSeek):单份 33~87 秒,清单 216~1348 条,正式清单里的条目都带页码和原句。发现并已修复:标注规则说明的碎片被当成条款、页码混进正文、跨页拆成两条、「做无效响应处理」等写法漏认、上级标题下列出的无效情形被合成一条或漏掉、「无效质疑」被误当成投标无效、「是否接受联合体」取错证据。
|
用国动便民小程序(71 页)、S02、S06 三份文件实测(真实模型 DeepSeek):单份 33~87 秒,清单 216~1348 条,正式清单里的条目都带页码和原句。发现并已修复:标注规则说明的碎片被当成条款、页码混进正文、跨页拆成两条、「做无效响应处理」等写法漏认、上级标题下列出的无效情形被合成一条或漏掉、「无效质疑」被误当成投标无效、「是否接受联合体」取错证据。
|
||||||
|
|
||||||
还没做、留了 TODO 的:转换镜像的字体包和 WPS 旧版 DOC 预处理(S1)、表格按单元格切句(S3)、OCR(S5)、章节层级和分包对应到清单项、模型路由的候选列表、降级和限流、在 S01–S10 上的标注集评测(召回率)、查标全部。
|
还没做、留了 TODO 的:转换镜像的字体包(S1)、OCR(S5)、分包对应到清单项、模型路由的候选列表、降级和限流、查标全部。S01–S10 标注集评测和修复见 `extract-eval.md`。
|
||||||
|
|||||||
219
docs/architecture/extract-eval.md
Normal file
219
docs/architecture/extract-eval.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# 读标抽取评测:S01–S10 标注集 v1(2026-11-03)
|
||||||
|
|
||||||
|
> **修复后的结果见 §7**:F1–F8 和四个口径问题已经处理,三项目标全部达标。§1–§6 是修复前(base)的评测,保留作对照。
|
||||||
|
|
||||||
|
> 范围:读标(M2)的五类清单抽取和项目基本信息,在 S01–S10 上的召回率和准确率。
|
||||||
|
> 模型:DeepSeek Flash 不思考模式(决策 M1,不再对比 Pro 和思考模式);提示词 substantive v4、qualification / scoring / schedule / tech_param v3、project_info v2;代码版本 f99bb8b。
|
||||||
|
> 工具和复现方法见 `evals/README.md`;标注规范见 `evals/gold/GUIDELINES.md`。
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
| 指标 | 目标(TV1) | 结果 | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 标注类实质性条款召回(★ / * / 斜体下划线) | ≥ 99% | 正式清单 99.4%,含待确认区 **99.7%**(346/347) | ✓ |
|
||||||
|
| 写明无效类召回 | ≥ 90% | 正式清单 77.0%,含待确认区 **85.4%**(223/261) | ✗ |
|
||||||
|
| 进入正式清单的实质性条款准确率 | ≥ 95% | **87.8%**(811/924) | ✗ |
|
||||||
|
|
||||||
|
- **标注类达标**:规则兜底起了作用。唯一漏掉的一条,是 S07 那句既说明标注方式、又带 * 号的长句。
|
||||||
|
- **另外两项不达标,原因都在规则,不在模型**:
|
||||||
|
- 写明无效类:表格项靠表后的说明定性,「拒绝接收」这类写法没认出来。
|
||||||
|
- 准确率:合并清单项时,没通过校验的条目被并进了正式条目。
|
||||||
|
- **其他四类**:
|
||||||
|
|
||||||
|
| 类别 | 召回 | 准确率 | 主要问题 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 资格条件 | 98.9% | **38.4%** | 模型多抽 |
|
||||||
|
| 评分项 | 93.8%(含待确认 99.5%) | 100% | |
|
||||||
|
| 日期与递交 | 100% | 73.7% | |
|
||||||
|
| 技术参数(抽样页) | 91.1% | 98.6% | 召回差在一种静默丢失:分块输出接近上限时,模型提前收尾 |
|
||||||
|
|
||||||
|
- **项目信息**:12 个字段里 8 个全对。`eval_method` 只对 3/10,`sme_only` 只对 1/10,都是规则问题(§3.6)。
|
||||||
|
- **成本**:10 份文件全部任务,输入 386 万 token,输出 76 万 token,约 13.8 元(高峰价,未计缓存命中),**平均每份约 1.4 元**。tech-validation §4.3 推算的是 0.6~1.0 元,实测偏高,主要多在 S03、S06 这两份长文件的技术参数输出。D2 定额度时按实测值算。
|
||||||
|
|
||||||
|
## 2. 方法
|
||||||
|
|
||||||
|
- **标注集**:`evals/gold/S01.json`~`S10.json`,共 1,829 个单元(其中 76 个是裁定后补的,见下文)。
|
||||||
|
- 每份文件由一个子代理按 `GUIDELINES.md` 标注。子代理只看文字层(`sentences.txt`),不看模型输出。
|
||||||
|
- 定位用「页码 + 原文片段」,切句规则变了也能用。
|
||||||
|
- 五类清单和项目信息全文标注。技术参数量太大,每份在技术要求章里等距抽 6 页,这 6 页上全标。
|
||||||
|
- **目前是 AI 标注、AI 复核,没有人工复核**(`reviewed: false`)。
|
||||||
|
- **口径统一**:标完后发现各份的处理不一致,统一了三条:句子本身带标注的一律必标;章节标题带 ★ 的继承暂记可选;`sme_only` 原文没写明的改为 null(GUIDELINES §4)。
|
||||||
|
- **打分**(`evals/score.py`):
|
||||||
|
- 召回:有对应类别的清单项,出处句子和标注的句子有交集,就算召回。正式清单算一遍,加上待确认区再算一遍。
|
||||||
|
- 准确率:正式清单里这一类的条目,出处和这一类的某条标注有交集,就算准确。
|
||||||
|
- `optional` 的标注不进召回的分母,抽到了算对。
|
||||||
|
- **裁定**:正式清单里对不上标注的 543 条,逐条判断是标注漏了还是模型抽错了。
|
||||||
|
- 实质性条款、资格条件、日期递交由三个子代理裁定,评分和技术参数我自己裁定。
|
||||||
|
- 标注漏了的有 76 条,补成 `optional` + `added_in_review`:只修正准确率,不抬高召回率。
|
||||||
|
- 抽错的按原因分类,结果在 §3。
|
||||||
|
- **S07 的预处理**:S07 是 WPS 旧版 DOC,产品现在的转换流程直接转,正文全是「第第第第」(S1 §1.3 记录过,转换流程还没做这一步)。评测时在 `prepare.py` 里先转 DOCX、修正字符集。**这是产品缺陷,要在转换流程里补上。**
|
||||||
|
|
||||||
|
## 3. 问题和原因
|
||||||
|
|
||||||
|
按「改规则能解决」和「要改提示词」分开。规则只修真正的缺陷,不迁就模型的错误输出(SK11 的原则)。
|
||||||
|
|
||||||
|
### 3.1 合并清单项时,没通过校验的条目被并进正式条目(规则缺陷,影响最大)
|
||||||
|
|
||||||
|
- **现象**:`merge_items` 按首句合并各任务的结果,只跳过「同一任务」的待确认条目。别的任务的待确认条目照样把类别、重要性和出处并进正式条目。
|
||||||
|
- **后果**:实质性条款任务里依据校验没通过的条目,借技术参数、资格条件的正式条目进了正式清单,还被标成实质性。
|
||||||
|
- 实质性条款多出来的 113 条里,89 条原文根本没写无效后果,大部分是这样来的。
|
||||||
|
- S07 一份就有 93 条:普通技术参数被标成了「致命」。
|
||||||
|
- **修复**:合并时,待确认条目不给正式条目加类别、不提高重要性。它单独留在待确认区,或作为待确认的依据附在正式条目上。
|
||||||
|
|
||||||
|
### 3.2 写明无效类的漏抽(规则为主)
|
||||||
|
|
||||||
|
| 原因 | 条数 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 表格项靠表后的说明定性:「上述第 1~6 项必须全部提供,否则投标无效」写在资格证明表、符合性审查表**下面**。结构识别只往上找上级条款,看不到它 | 33(S03、S05、S07、S08) | 结构识别加一种:以「上述」开头、写明无效后果的说明,覆盖它上方同一张表的各项 |
|
||||||
|
| 后果写在另一条里:「有下列情形之一的,视为串通投标」下的各项,「串通投标的投标无效」写在别处 | 7 | 「视为串通投标」作为无效的上级标题。依据:87 号令,串通投标的投标无效 |
|
||||||
|
| 无效写法没认全:「其投标将被拒绝」「拒绝接收」「拒绝接受」「无资格参加」 | 7 | `INVALID` 补这几种写法 |
|
||||||
|
| 符合性审查表各项只在表头写了「不通过的投标无效」,上级关系没识别出来 | 13 | 和第一行一起做:表格结构识别 |
|
||||||
|
|
||||||
|
### 3.3 资格条件多抽(提示词为主)
|
||||||
|
|
||||||
|
正式清单里 466 条资格条件,裁定后 287 条不是资格条件:
|
||||||
|
|
||||||
|
| 抽错的内容 | 条数 |
|
||||||
|
|---|---|
|
||||||
|
| 技术参数,尤其是写着「提供认证证书 / 检测报告」的参数行 | 112 |
|
||||||
|
| 投标文件的组成、编制、递交、签章、授权书 | 54 |
|
||||||
|
| 无效情形、串通情形 | 34 |
|
||||||
|
| 第六章格式范本里的声明函、承诺函正文(提示词已经禁止,模型照样抽) | 27 |
|
||||||
|
| 价格扣除、联合体组成规则 | 18 |
|
||||||
|
| 合同履约、服务要求 | 11 |
|
||||||
|
| 程序说明 | 5 |
|
||||||
|
| 其他 | 26 |
|
||||||
|
|
||||||
|
- **规律**:句子里带「资格」「证明材料」「须提供」就收。
|
||||||
|
- **修复**:
|
||||||
|
- **规则**:结构识别补上章节层级(架构 §3.5 的 TODO)。第六章格式范本和第四章技术参数,不交给资格任务。这一项能去掉一半以上的错。
|
||||||
|
- **提示词**:qualification v4 把「技术参数里的证书要求」「投标文件组成和签章」「无效情形」写进「不要输出」,各给一个例子。
|
||||||
|
|
||||||
|
### 3.4 日期与递交多抽(提示词)
|
||||||
|
|
||||||
|
88 条对不上标注,裁定后 29 条是标注漏了(同一要求在另一处换了说法),59 条是模型抽错了:
|
||||||
|
|
||||||
|
- 质疑投诉的程序和形式 23 条;
|
||||||
|
- 运维服务时限、驻场人数 11 条,主要在 S09,模型看到时限、人数就当成了日期;
|
||||||
|
- 开标记录 5 分钟内提疑义 7 条;
|
||||||
|
- 中标后事项 5 条;
|
||||||
|
- 其他 13 条。
|
||||||
|
|
||||||
|
**修复**:schedule v4 的「不要输出」补这几类。
|
||||||
|
|
||||||
|
### 3.5 技术参数漏抽(规则 + 提示词)
|
||||||
|
|
||||||
|
- **静默丢失(规则)**:S03 一个分块有 259 句密集参数表。模型输出 97 条、7,757 个 token(上限 8,192)就自己收了尾,JSON 完整,没被判成截断,后面两页多的参数整段丢了(抽样页上 17 条)。
|
||||||
|
- **修复**:输出 token 超过上限的 90%,且最后一条证据离分块末尾还远,按截断处理、对半重跑。技术参数任务的分块也可以改小。
|
||||||
|
- **描述性的实施、服务要求被当成背景(提示词)**:S05、S06、S08、S09、S10 共约 30 条,例如「须统一部署于市级政务云」「试运行不少于 1 个月」。
|
||||||
|
- **修复**:tech_param v4 写明:带「须」「不少于」「不得」的实施、服务要求要抽,项目背景和建设目标才不抽。
|
||||||
|
|
||||||
|
### 3.6 项目信息(规则)
|
||||||
|
|
||||||
|
- **`eval_method` 7/10 没答出来**:「本项目采用综合评分法」写在第五章评标办法里,项目信息只看前 3 个分块。
|
||||||
|
- **修复**:项目信息任务再加一个分块,就是评标办法一章的开头。
|
||||||
|
- **`sme_only` 8/10、`accept_imported` 2/10 核对不通过**:是 / 否字段的否定词表没有「非」「不专门」,「非专门面向中小企业」被判成证据和取值对不上。
|
||||||
|
- **修复**:否定词表补上。
|
||||||
|
|
||||||
|
### 3.7 其他
|
||||||
|
|
||||||
|
- **评分项待确认区里 68% 是好结果**(17/25):被拦下的原因是引文不是原文、分值找不到,都出在评分表被切碎的地方。这正好是已经排好的「表格按单元格切句」。
|
||||||
|
- **重复条目**:
|
||||||
|
- 同一条标注被两条以上正式条目命中:实质性条款 115、资格条件 41、日期递交 35。
|
||||||
|
- 主要原因是同一要求在公告、前附表、审查表里各出现一次,各抽一条。
|
||||||
|
- 这本身不算错,但清单会显得重复。查标阶段要考虑按语义合并。
|
||||||
|
|
||||||
|
## 4. 修复清单(按收益排序)
|
||||||
|
|
||||||
|
| # | 改什么 | 类型 | 预计影响 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F1 | 合并时待确认条目不给正式条目加类别、提重要性(§3.1) | 规则 | 实质性条款准确率 87.8% → 95% 以上 |
|
||||||
|
| F2 | 结构识别:「上述……否则无效」往上覆盖表格各项;「视为串通投标」算无效标题;`INVALID` 补写法(§3.2) | 规则 | 写明无效类召回 85% → 95% 左右 |
|
||||||
|
| F3 | 静默提前收尾按截断处理、对半重跑(§3.5) | 规则 | 技术参数召回,S03 这类长表 |
|
||||||
|
| F4 | 章节层级:第六章格式、第四章参数不交给资格任务(§3.3) | 规则 | 资格条件准确率 |
|
||||||
|
| F5 | qualification v4、schedule v4、tech_param v4 的「只找 / 不要输出」(§3.3–3.5) | 提示词 | 资格、日期准确率,技术参数召回 |
|
||||||
|
| F6 | 项目信息加评标办法分块;是 / 否字段否定词补「非」(§3.6) | 规则 | `eval_method`、`sme_only` |
|
||||||
|
| F7 | 转换流程补 WPS 旧版 DOC 预处理(§2) | 规则 | S07 这类文件能用 |
|
||||||
|
| F8 | 表格按单元格切句(已排期) | 规则 | 评分项、技术参数的待确认 |
|
||||||
|
|
||||||
|
## 5. 口径问题(已定,见 decisions.md Q1–Q4)
|
||||||
|
|
||||||
|
标注时遇到、规范里没定的,影响产品行为,需要拍板:
|
||||||
|
|
||||||
|
1. **★ 标在整节标题上**(S01「三、★商务要求」并写明商务条款全部是实质性、负偏离无效;S07「★售后」;S10「★(一)技术要求」「★(二)商务要求」):节里不带 ★ 的各句算不算实质性条款?
|
||||||
|
- 现在的流水线只认标题这一句,标注集暂把节里的句子记为可选。
|
||||||
|
2. **★ 没写无效后果**(S02):文件只说「★ 为核心技术参数」,评分表里 ★ 不满足只扣 4 分。
|
||||||
|
- 现在一律按实质性处理,用户会看到「致命」,实际只是扣分。
|
||||||
|
3. **「必须 / 应」都是实质性要求**(S07 第 18 页的声明):逐句标出不现实。
|
||||||
|
- 建议只在文件层面提示「本文件声明含『必须』『应』字样的要求均为实质性要求」,不逐句抽。
|
||||||
|
4. **资格条件是否一律带「无效」类别**:政府采购里资格审查不合格就是投标无效,87 号令第六十三条有规定。
|
||||||
|
- 一律带上的话,§3.2 第一行那 33 条漏抽自然解决。
|
||||||
|
- 但写明无效类的口径会从「文件写明」变成「法规规定」,要和「双层口径」一并考虑。
|
||||||
|
|
||||||
|
## 6. 没覆盖的
|
||||||
|
|
||||||
|
- 标注集没有人工复核。
|
||||||
|
- 1,829 个单元是 AI 标的;裁定时发现,各份文件之间对「授权书算不算资格」「质疑截止算不算日期」的处理仍有差异。
|
||||||
|
- 建议先人工抽查实质性条款(S03、S06 最难),再把 `reviewed` 改成 true。
|
||||||
|
- 样本缺口没变:扫描件、补遗、招标投标体系、配套投标文件。
|
||||||
|
- 原句高亮准确率(FR-M2-06)不在本次范围,等表格按单元格切句后再验。
|
||||||
|
|
||||||
|
## 7. 修复后(fix3,2026-11-03)
|
||||||
|
|
||||||
|
F1–F8 全部实现,四个口径按建议执行(决策 Q1–Q4)。改完先请 Fable 当顾问审读(§7.3),改了它指出的问题,再跑两轮。
|
||||||
|
|
||||||
|
### 7.1 结果
|
||||||
|
|
||||||
|
| 指标 | 目标 | 修复前(base) | 修复后(fix3) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 标注类实质性条款召回(含待确认区) | ≥ 99% | 99.7% | **99.4%** ✓ |
|
||||||
|
| 写明无效类召回(含待确认区) | ≥ 90% | 85.4% | **98.1%** ✓(正式清单 84.7%) |
|
||||||
|
| 实质性条款准确率(正式清单) | ≥ 95% | 87.8% | **98.4%** ✓ |
|
||||||
|
| 资格条件 召回 / 准确率 | | 98.9% / 38.4% | 97.7% / **90.0%** |
|
||||||
|
| 评分项 召回 / 准确率 | | 93.8% / 100% | **97.4%** / 100% |
|
||||||
|
| 日期与递交 召回 / 准确率 | | 100% / 73.7% | 95.6% / **99.3%** |
|
||||||
|
| 技术参数(抽样页) 召回 / 准确率 | | 91.1% / 98.6% | **98.4%** / 98.8% |
|
||||||
|
| 项目信息:评标方法 / 最高限价 / 是否专门面向中小企业 | | 3 / 9 / 1(共 10 份) | **9** / **10** / **7** |
|
||||||
|
| 每份文件的成本(Flash 高峰价) | | 约 1.4 元 | **约 1.2 元**(按章跳过,输入少了 27%) |
|
||||||
|
|
||||||
|
- **口径**:两次的标注集不完全相同。修复后按 Q1、Q2 改了口径(S01 节内 12 条改必标,S02 的 11 条 ★ 改可选),技术参数的 S07、S09 在新分页上重标,两轮裁定共补了 117 个可选单元。召回率的分母基本没变(597 → 610)。
|
||||||
|
- **裁定**:准确率都是裁定过的,正式清单里对不上标注的条目逐条判断过,方法同 §2。
|
||||||
|
|
||||||
|
### 7.2 改了什么
|
||||||
|
|
||||||
|
| # | 改动 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | 合并时,待确认条目不再给正式条目加类别、提重要性;没通过的类别记在正式条目的 `ai_values.pending_categories` 里,不丢 | `extract.merge_items`、`add_pending_categories` |
|
||||||
|
| F2 | 表后往回指的说明(「上述……否则投标无效」)覆盖上方的编号条目和表格行;「视为串通投标」算无效依据;`INVALID` 补「被拒绝」「拒绝接收」「投标文件无效」「不实质性响应」等写法;规则兜底也认表后说明 | `structure.trailing_notes`、`extract.INVALID` |
|
||||||
|
| F3 | 输出用到上限的 92% 以上按截断处理、对半重跑;拆到底时采用已有输出 | `ai/providers/base.near_limit` |
|
||||||
|
| F4 | 章识别(公告、须知、合同、采购需求、评审、格式……),资格、日期、参数、评分任务跳过不相干的章;章认不可靠时不跳过 | `structure.chapters`、`extract.SKIP_CHAPTERS` |
|
||||||
|
| F5 | 提示词 substantive v5、qualification v4、schedule v4、tech_param v4;规则兜底:格式范本正文(「我单位……」)、列表引导句和整表概括句不收 | `prompts/`、`extract.TEMPLATE`、`BLANKET` |
|
||||||
|
| F6 | 项目信息多看写着「本项目采用综合评分法」的那一块;是 / 否字段否定词补「非」「不专门」 | `extract.EVAL_METHOD`、`NEGATIVE` |
|
||||||
|
| F7 | WPS 旧版 DOC:直接修正 DOC 字体表里中文字体的字符集,不用先转 DOCX | `convert.fix_wps_doc_charset` |
|
||||||
|
| F8 | 有框线的表格按单元格切句(切句 s3-v4),竖排窄列、两栏表格不再串行 | `text_layer._fragments` |
|
||||||
|
| Q1 | ★ 节标题下的条款继承实质性 | `structure.star_sections` |
|
||||||
|
| Q2 | ★ 被定义成计分项时降为「重要」,条件从严 | `extract.star_scored` |
|
||||||
|
| Q3 | 「必须 / 应」声明记成 keyword 标注规则 | `extract.marking_notes`、`checklist.service.merge` |
|
||||||
|
| Q4 | 政府采购项目的资格条件带「无效」类别,依据记 statute | `extract._qualification` |
|
||||||
|
|
||||||
|
### 7.3 顾问审读(Fable)
|
||||||
|
|
||||||
|
请 Fable 只读审了第一版修复,确定的问题 7 条,都已改正:
|
||||||
|
1. ★ 定义句的降级会误伤真正的废标 ★:改成要有计分证据,而且全文没有带 ★ 的后果写法;
|
||||||
|
2. 章识别会被目录、正文里的引用带偏:排除编号条目,同样长取靠后的序列,不可靠时不跳过;
|
||||||
|
3. F1 第一版把待确认条目直接丢了:改成记在 `pending_categories`;
|
||||||
|
4. Q2 文件里实质性任务仍标致命:改为按重要性规则定级;
|
||||||
|
5. 表后说明会把标注说明、否定句当成无效依据:排除这两种写法;
|
||||||
|
6. 参数行带 ★ 编号被当成节标题:要求 ★ 紧挨编号,标题短、不带标点和数字;
|
||||||
|
7. 接近上限拆到底时丢弃完整输出:改为采用已有输出。
|
||||||
|
|
||||||
|
推测性的几条也采纳了:合同章里的「拒绝接受」不算无效依据,引导句过滤只针对冒号结尾或很短的句子,F7 收紧字体记录长度的检查。
|
||||||
|
|
||||||
|
### 7.4 还剩的问题
|
||||||
|
|
||||||
|
- **写明无效类正式清单召回只有 84.7%**:大部分在待确认区(规则兜底或依据校验没过),用户能看到,但要多点一下。主要是符合性审查表各项(表头写「任何一项不符合即无效」,在表格上方),以及串通情形。
|
||||||
|
- **待确认区里只有 62% 是好结果**(127/205):规则兜底和 `pending_categories` 带进来的噪音偏多。查标阶段要看待确认区的处理体验再定要不要收紧。
|
||||||
|
- **资格条件准确率 90%**:剩下的错误主要是「本项目不接受进口产品 / 分包」被当成资格条件,以及联合体、中小企业政策条款。
|
||||||
|
- **项目信息**:`accept_imported`、`sme_only` 各 7/10,多数是模型答「有歧义」或没核对通过。
|
||||||
|
- **★ 节里的商务条款**(S01 付款方式、履约保证金):规则兜底不对继承来的句子逐句生成待确认,模型没抽到就漏了。
|
||||||
|
- **标注集没有人工复核**,口径同 §6。
|
||||||
@@ -4,6 +4,25 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Q1–Q4 · 2026-11-03 · 实质性条款的四个口径:全部按建议
|
||||||
|
|
||||||
|
依据 `docs/architecture/extract-eval.md` §5(S01–S10 标注时遇到、规范里没定的情形)。
|
||||||
|
|
||||||
|
| # | 决策 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | ★ 标在节标题上(「三、★商务要求」「★(一)技术要求」),节里的条款都是实质性要求 | 文件自己这样声明(S01 还写明商务条款负偏离即无效);节的范围到下一个同级编号为止 |
|
||||||
|
| Q2 | 文件把 ★ 定义成计分项(「加★为核心技术参数」、不满足只扣分),而且没有任何地方说 ★ 是实质性要求或写明无效后果,★ 降为「重要」,标注规则记 meaning = important | 否则用户会把扣分项当成废标项(S02)。条件从严:要有计分证据、全文没有带 ★ 的后果写法,拿不准就按实质性 |
|
||||||
|
| Q3 | 文件声明「必须」「应」字样的要求都是实质性要求时(S07),只在文件层面记一条标注规则(marker = keyword),不逐句抽 | 逐句抽会把几百条一般要求都标成致命,清单失去意义 |
|
||||||
|
| Q4 | 政府采购项目的资格条件一律带「无效」类别,重要性依据记 statute | 87 号令第六十三条:不具备招标文件规定的资格要求的,投标无效;磋商同理。这是现行规定,不是草案层 |
|
||||||
|
|
||||||
|
- **影响**:`extract.build_doc`、`structure.star_sections`;界面上 statute 来源的「无效」要和「文件写明」分开展示(查标阶段做)。
|
||||||
|
|
||||||
|
## M1 · 2026-11-03 · 抽取模型只用 Flash,靠规则和提示词优化
|
||||||
|
|
||||||
|
- **决策**:读标的全部抽取任务都用 DeepSeek Flash 不思考模式(`deepseek-chat`,接口返回 `deepseek-flash`),不再对比 Pro 和思考模式。提升效果只在规则(校验、兜底、文字层、结构识别)和提示词之间做。
|
||||||
|
- **理由**:SK11 在实质性条款任务上比较过 Flash 不思考、Flash 思考(`deepseek-reasoner`)和 `deepseek-v4-pro`,抽到的条款大体一致、互有多少,后两者慢 15~45 倍,输出 token 多 7~12 倍,看不出更准。
|
||||||
|
- **影响**:S01–S10 标注集评测只跑 Flash 基线,用来找规则和提示词的问题;按能力路由(`BD_AI_MODELS`)的机制保留,不配置。tech-validation §4.3 里「D2 定价前补测 Pro 或思考模式」不再做。
|
||||||
|
|
||||||
## API1–API5 · 2026-11-03 · 接口契约的取舍:全部按建议
|
## API1–API5 · 2026-11-03 · 接口契约的取舍:全部按建议
|
||||||
|
|
||||||
依据 `docs/architecture/api-design.md` §9;契约文件 `openapi.yaml`。
|
依据 `docs/architecture/api-design.md` §9;契约文件 `openapi.yaml`。
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# 评测
|
# 评测
|
||||||
|
|
||||||
- `run_extract.py`:离线跑一份招标文件的全部抽取任务,统计结果,并审计模型输出里的不合理模式(引文太短、同一句被拆成多条、编号和引文不是同一句、声称有标注但原文没有……)。改提示词、改规则、换模型前后各跑一次对比。用法见文件头。
|
- `run_extract.py`:离线跑一份招标文件的全部抽取任务,统计结果,并审计模型输出里的不合理模式(引文太短、同一句被拆成多条、编号和引文不是同一句、声称有标注但原文没有……)。改提示词、改规则、换模型前后各跑一次对比。用法见文件头。
|
||||||
- 输出放在 `evals/out/`(不进 git):里面有真实招标文件的内容。
|
- 输出放在 `evals/out/`(不进 git):里面有真实招标文件的内容。模型输出缓存在 `evals/out/cache/`,同一模型 + 提示词版本 + 输入只调用一次,只改规则时重放不花钱。
|
||||||
- TODO:S01–S10 的人工标注集和召回率、准确率计算(`prompts/README.md` 要求的回归评测)。
|
|
||||||
|
## S01–S10 标注集(v1,2026-11-03)
|
||||||
|
|
||||||
|
结果和分析见 `docs/architecture/extract-eval.md`。
|
||||||
|
|
||||||
|
- `gold/<ID>.json`:标注集,规范见 `gold/GUIDELINES.md`。用「页码 + 原文片段」定位,切句规则变了也能用。目前由 AI 标注、AI 复核(`reviewed: false`),还没有人工复核。
|
||||||
|
- `prepare.py`:样本转成规范化 PDF,导出带句子编号的全文 `out/docs/<ID>/sentences.txt`(标注时看它)。旧版 DOC 先在转换容器里转 DOCX(产品流程还缺这一步,见 TODO)。
|
||||||
|
- `gold.py check <ID>`:检查标注能不能全部定位。
|
||||||
|
- `run_all.sh <运行名>`:10 份文件跑全部抽取,结果在 `out/runs/<运行名>/`。
|
||||||
|
- `score.py <运行名>`:打分,输出 `out/score/<运行名>/summary.md`(各类召回率、准确率、项目信息正确率)和每份文件的漏抽、多抽清单。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/compose/compose.yaml up -d gotenberg
|
||||||
|
cd server
|
||||||
|
../.venv/bin/python ../evals/prepare.py # 一次即可
|
||||||
|
cd .. && evals/run_all.sh base && cd server
|
||||||
|
../.venv/bin/python ../evals/score.py base
|
||||||
|
```
|
||||||
|
|
||||||
|
改了规则或提示词之后,换一个运行名重跑、打分,和上一次的 `summary.md` 对比。目前的基线是 `fix3`(`docs/architecture/extract-eval.md` §7);`base` 是修复前的那一轮,用的是旧切句(s3-v3),不能拿现在的标注集重新打分。正式清单里多出来、对不上标注的条目要逐条裁定(GUIDELINES §4)。
|
||||||
|
|||||||
156
evals/gold.py
Normal file
156
evals/gold.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""标注集:读取、定位、校验(evals/gold/<ID>.json,规范见 evals/gold/GUIDELINES.md)。
|
||||||
|
|
||||||
|
标注用「页码 + 原文片段」定位,不用句子编号:切句规则一改编号就变,片段在同一页里照样找得到。
|
||||||
|
定位时把这一页的句子连起来找片段,片段覆盖到的句子都算这条标注的句子(切句变了也不影响)。
|
||||||
|
|
||||||
|
用法(在 server/ 下):../.venv/bin/python ../evals/gold.py check S01 [S02 ...]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bid_doctor.document.pipeline import text_layer
|
||||||
|
from bid_doctor.document.pipeline.text_layer import Sentence
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent
|
||||||
|
GOLD = ROOT / "gold"
|
||||||
|
DOCS = ROOT / "out" / "docs"
|
||||||
|
CATS = ("substantive", "qualification", "scoring", "schedule", "tech_param")
|
||||||
|
BASES = ("star", "asterisk", "italic_underline", "explicit_invalid")
|
||||||
|
INFO_KEYS = (
|
||||||
|
"name",
|
||||||
|
"code",
|
||||||
|
"purchaser",
|
||||||
|
"agency",
|
||||||
|
"procurement_method",
|
||||||
|
"legal_regime",
|
||||||
|
"budget_total",
|
||||||
|
"ceiling_total",
|
||||||
|
"eval_method",
|
||||||
|
"accept_consortium",
|
||||||
|
"accept_imported",
|
||||||
|
"sme_only",
|
||||||
|
)
|
||||||
|
SHIFT = 3 # 标注页码找不到时,往前后各找这么多页
|
||||||
|
MARKUP = re.compile(r"</?(?:iu|u|i)>|〔[^〕]*〕|^\[p\d+s\d+\]\s*")
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s: str) -> str:
|
||||||
|
return text_layer.norm(MARKUP.sub("", s))
|
||||||
|
|
||||||
|
|
||||||
|
def sid(s: Sentence) -> str:
|
||||||
|
return f"p{s.page_no}s{s.seq}"
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def sentences(doc_id: str) -> list[Sentence]:
|
||||||
|
pages = text_layer.extract("D", (DOCS / doc_id / "normalized.pdf").read_bytes())
|
||||||
|
return text_layer.body_sentences(pages)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Unit:
|
||||||
|
id: str
|
||||||
|
cat: str
|
||||||
|
sids: set[str]
|
||||||
|
optional: bool
|
||||||
|
raw: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def locate(doc_id: str, page: int, text: str, nth: int = 1) -> tuple[list[str], int]:
|
||||||
|
"""片段覆盖到的句子编号,以及片段在那一页出现的次数。先在标注的页码上找;找不到就到前后 SHIFT 页里按远近找
|
||||||
|
(转换方式、字体变了,分页会移动几页,例如 S07 从「先转 DOCX」改成直接修正 DOC 字体表)。"""
|
||||||
|
for p in sorted(range(page - SHIFT, page + SHIFT + 1), key=lambda x: abs(x - page)):
|
||||||
|
hits, n = _locate_on(doc_id, p, text, nth)
|
||||||
|
if hits:
|
||||||
|
return hits, n
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_on(doc_id: str, page: int, text: str, nth: int = 1) -> tuple[list[str], int]:
|
||||||
|
sents = [s for s in sentences(doc_id) if s.page_no == page]
|
||||||
|
joined, spans = "", []
|
||||||
|
for s in sents:
|
||||||
|
t = norm(s.text)
|
||||||
|
spans.append((len(joined), len(joined) + len(t), sid(s)))
|
||||||
|
joined += t
|
||||||
|
q = norm(text)
|
||||||
|
if not q:
|
||||||
|
return [], 0
|
||||||
|
at = -1
|
||||||
|
for _ in range(nth): # 同一页上逐字相同的两段(两台设备的同一条参数):n 指第几次出现
|
||||||
|
at = joined.find(q, at + 1)
|
||||||
|
if at < 0:
|
||||||
|
return [], 0
|
||||||
|
hits = [k for b, e, k in spans if b < at + len(q) and e > at]
|
||||||
|
return hits, joined.count(q)
|
||||||
|
|
||||||
|
|
||||||
|
def load(doc_id: str) -> dict[str, Any]:
|
||||||
|
return json.loads((GOLD / f"{doc_id}.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def units(doc_id: str) -> tuple[list[Unit], list[str]]:
|
||||||
|
"""标注单元(定位到句子编号)和定位时的问题。"""
|
||||||
|
g = load(doc_id)
|
||||||
|
out, problems = [], []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for it in g.get("items", []):
|
||||||
|
uid = str(it.get("id"))
|
||||||
|
if uid in seen:
|
||||||
|
problems.append(f"{uid}:编号重复")
|
||||||
|
seen.add(uid)
|
||||||
|
if it.get("cat") not in CATS:
|
||||||
|
problems.append(f"{uid}:cat 不对({it.get('cat')})")
|
||||||
|
if it.get("cat") == "substantive" and it.get("basis") not in BASES:
|
||||||
|
problems.append(f"{uid}:substantive 要写 basis({it.get('basis')})")
|
||||||
|
sids: set[str] = set()
|
||||||
|
for a in it.get("anchors") or []:
|
||||||
|
hits, n = locate(doc_id, int(a.get("p", 0)), str(a.get("t", "")), int(a.get("n", 1)))
|
||||||
|
if not hits:
|
||||||
|
problems.append(f"{uid}:第 {a.get('p')} 页找不到「{a.get('t')}」")
|
||||||
|
elif n > 1 and len(norm(str(a.get("t", "")))) < 12 and "n" not in a:
|
||||||
|
problems.append(f"{uid}:「{a.get('t')}」在第 {a.get('p')} 页出现 {n} 次,片段再长一点")
|
||||||
|
sids.update(hits)
|
||||||
|
if not it.get("anchors"):
|
||||||
|
problems.append(f"{uid}:没有 anchors")
|
||||||
|
out.append(Unit(uid, str(it.get("cat")), sids, bool(it.get("optional")), it))
|
||||||
|
info = g.get("project_info") or {}
|
||||||
|
missing = [k for k in INFO_KEYS if k not in info]
|
||||||
|
if missing:
|
||||||
|
problems.append(f"project_info 缺字段:{missing}")
|
||||||
|
return out, problems
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) < 3 or sys.argv[1] != "check":
|
||||||
|
print(__doc__)
|
||||||
|
return
|
||||||
|
for doc_id in sys.argv[2:]:
|
||||||
|
us, problems = units(doc_id)
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for u in us:
|
||||||
|
key = (
|
||||||
|
u.cat
|
||||||
|
+ ("/" + str(u.raw.get("basis")) if u.cat == "substantive" else "")
|
||||||
|
+ ("(可选)" if u.optional else "")
|
||||||
|
)
|
||||||
|
counts[key] = counts.get(key, 0) + 1
|
||||||
|
print(f"{doc_id}:{len(us)} 条," + ",".join(f"{k} {n}" for k, n in sorted(counts.items())))
|
||||||
|
print(f" 技术参数抽样页:{load(doc_id).get('tech_param_pages')}")
|
||||||
|
for p in problems:
|
||||||
|
print(" ✗", p)
|
||||||
|
if not problems:
|
||||||
|
print(" ✓ 全部定位成功")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
137
evals/gold/GUIDELINES.md
Normal file
137
evals/gold/GUIDELINES.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 标注规范(S01–S10 标注集 v1)
|
||||||
|
|
||||||
|
标注集用来算抽取的召回率和准确率(FR-M2-05,`docs/architecture/tech-validation.md` §5)。每份文件一个 `<ID>.json`。
|
||||||
|
标注的口径就是产品对每一类的定义(`prompts/extract.*` 最新版本的「只找这些」「不要输出」),下面写的是落到标注上的具体规则。
|
||||||
|
|
||||||
|
## 0. 原则
|
||||||
|
|
||||||
|
- **只看原文**:`evals/out/docs/<ID>/sentences.txt`(规范化 PDF 的文字层,每行 `[p12s3] 句子`)。不看 `evals/out/runs/`、`evals/out/cache/` 里的模型输出,避免照着模型标。
|
||||||
|
- **一条要求一个单元**。漏标会让准确率偏低,多标会让召回率偏低,拿不准的标成 `"optional": true`(抽到算对,没抽到不算漏)。
|
||||||
|
- 标完运行 `cd server && ../.venv/bin/python ../evals/gold.py check <ID>`,所有片段都要定位成功。
|
||||||
|
|
||||||
|
## 1. 定位:anchors
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "q3", "cat": "qualification", "anchors": [{"p": 5, "t": "具有独立承担民事责任的能力"}], "note": "通用资格:独立承担民事责任"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `p` 是页码(`=== 第 N 页`),`t` 是从这一页**逐字复制**的原文片段:8~30 个字,去掉行首 `[p5s3]`、格式标记 `<iu>`、行尾〔上级:…〕。片段在这一页里要唯一(太常见就长一点)。
|
||||||
|
- 片段可以跨两句(表格被切开时),覆盖到的句子都算。
|
||||||
|
- 同一页上逐字相同的两段(两台设备的同一条参数),片段加长也区分不开时,加 `"n": 2` 指第几次出现。
|
||||||
|
- 一条要求写在几句里(表格同一行的几格、跨页):每句一个 anchor。只给写着要求本身的句子,不给章节标题、序号单元格、表头。
|
||||||
|
- **同一条要求在文件里出现在几处**(公告和须知前附表、资格条件和资格审查表、技术参数表和偏离表说明):每一处都列成 anchor。评测时命中任一处就算召回,抽到任一处都算对。
|
||||||
|
- `note` 用自己的话概括这条要求(不超过 30 字),不要整段复制原文。
|
||||||
|
|
||||||
|
## 2. 五类要求(`cat`)
|
||||||
|
|
||||||
|
### substantive:实质性条款(不满足就投标无效、被否决)
|
||||||
|
|
||||||
|
每个单元写 `basis`:
|
||||||
|
|
||||||
|
| basis | 什么样的句子 |
|
||||||
|
|---|---|
|
||||||
|
| `star` | 带 ★ 的条款,包括表格里带 ★ 的参数行 |
|
||||||
|
| `asterisk` | 文件的标注规则说明里声明了 * 号是实质性标注时,带 * 的条款 |
|
||||||
|
| `italic_underline` | 文件声明了「斜体且有下划线」是实质性标注时,条款正文是斜体下划线的句子(`<iu>`)。只有一部分是 `<iu>` 的句子,`<iu>` 部分是一项要求就算 |
|
||||||
|
| `explicit_invalid` | 这句话、紧挨着的上一句或下一句,或它的上级条款(「有下列情形之一的,投标无效:」「三、符合条件(必须提供,否则投标无效)」「资格审查不通过的,投标无效」)写明了不满足就投标无效、响应无效、无效投标、作无效处理、视为未实质性响应、否决投标、不予受理、予以拒绝、不能通过资格审查 |
|
||||||
|
|
||||||
|
- 一句同时带 ★ 又写明无效,按 `star` 记一条。
|
||||||
|
- 上级条款下的列表:每一项一个单元;引导句本身(「有下列情形之一的,投标无效:」)不算单元,除非它自己就写着一项要求。
|
||||||
|
- 不算:说明标注方式的句子(「加★为实质性要求」「斜体且有下划线部分为实质性要求」)、只是提到 ★ 的句子(「★号条款响应」)、偏离表和响应表的表头与填写说明;合同履约、中标后义务、付款、验收、售后、保密、违约;投标保证金不予退还;质疑投诉程序;评分扣分规则;整个采购活动废标、终止(供应商不足三家等)。
|
||||||
|
- 「中标无效」「成交无效」(中标后才追究的,如提供虚假材料):`optional`。
|
||||||
|
- 资格审查表、符合性审查表里的每一项,只有表或上级条款写明了「不通过 / 不符合的投标无效」才算 `explicit_invalid`。
|
||||||
|
- 87 号令第六十条的异常低价条款(「不能证明其报价合理性的……作为无效投标处理」)算 `explicit_invalid`。
|
||||||
|
- 第六章格式里,范本正文(「我单位郑重声明……」)不算;格式里的注释如果写明了无效后果(「注:未按此格式提供的,投标无效」)算。
|
||||||
|
|
||||||
|
### qualification:资格条件
|
||||||
|
|
||||||
|
- 通用资格(《政府采购法》第二十二条等):文件逐项列出的,每项一个单元;只有一句「满足第二十二条规定」的,算一个单元。
|
||||||
|
- 特定资格:资质证书、许可证、业绩、人员资格、注册资本;是否接受联合体;专门面向中小企业;信用要求(信用中国、政府采购严重违法失信记录等)。
|
||||||
|
- 资格要求常出现在公告「申请人的资格要求」、须知、资格审查表、资格证明材料清单等处:同一项的每一处都列成 anchor。
|
||||||
|
- 不算:评分加分条件;中标后的履约要求;采购需求里的项目团队配置;响应文件编制、递交、报价、有效期;第六章格式(声明函、承诺函、授权书、联合体协议)的范本文字;价格扣除优惠及其声明函;说明标注方式的句子。
|
||||||
|
|
||||||
|
### scoring:评分项
|
||||||
|
|
||||||
|
- 评分表里的每一个评分因素一个单元(价格分、技术分、商务分下面的每个因素),中小企业等价格扣除规则也各算一个单元。
|
||||||
|
- anchors 给这个因素所在行的句子:因素名称、分值、评分标准(标准很长时给写着分值和得分条件的句子,最多 8 个)。写 `points`(分值的原文写法,如「10」「0-5」)。
|
||||||
|
- 不算:资格审查、符合性审查的条件;评审程序的一般说明。
|
||||||
|
|
||||||
|
### schedule:关键日期与递交要求
|
||||||
|
|
||||||
|
每个单元写 `kind`:
|
||||||
|
|
||||||
|
| kind | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `question_deadline` | 提出询问、质疑的截止时间(写了具体时间或期限的) |
|
||||||
|
| `bid_deadline` | 投标(响应)文件递交截止时间 |
|
||||||
|
| `opening` | 开标(磋商)时间、地点方式 |
|
||||||
|
| `validity` | 投标(响应)有效期 |
|
||||||
|
| `deposit` | 投标保证金:金额、形式,或写明不收 |
|
||||||
|
| `submission` | 递交方式:电子投标、CA 加密上传、正副本份数、密封 |
|
||||||
|
| `decryption` | 开标后的解密要求(时间、方式) |
|
||||||
|
| `signature` | 投标文件签字、盖章、电子签章的要求 |
|
||||||
|
|
||||||
|
- 同一个日期在公告、须知前附表里重复出现:一个单元,每处都是 anchor。
|
||||||
|
- 不算:合同履行期、交付期、质保期;采购文件的发布、获取时间;合同签订;一般的编制要求、撤回修改程序;格式范本。
|
||||||
|
- `signature` 类容易零散,只标须知、前附表里成句的要求,格式页里的「(盖章)」「签字:」不标。
|
||||||
|
|
||||||
|
### tech_param:技术、服务、功能要求(抽样标注)
|
||||||
|
|
||||||
|
全部标注工作量太大,按页抽样:
|
||||||
|
|
||||||
|
1. 找出采购需求章(一般是第四章)里技术、功能、服务要求所在的页码范围 [a, b](不含项目背景、商务条款、付款验收)。
|
||||||
|
2. 在里面等距取 6 页:a + round(i × (b − a) / 5),i = 0…5;范围不到 6 页就全取。写进 `tech_param_pages`。
|
||||||
|
3. 这几页上**每一句可以在投标文件里逐条核对的要求**是一个单元:硬件软件参数、功能、性能、安全、接口要求,驻场人数、响应时间、培训等服务要求。一句里并列几项的算一条;参数名和参数值分在两格的,两句都给 anchor。
|
||||||
|
4. 不算:项目背景、建设目标、现状;合同、付款、验收;评分标准;格式范本;表头。
|
||||||
|
5. 这几页上带 ★ 的参数行既是 `tech_param` 单元,也是 `substantive` 单元(各记一条)。
|
||||||
|
|
||||||
|
## 3. 项目基本信息:project_info
|
||||||
|
|
||||||
|
12 个字段都要写,文件没写的填 `null`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"project_info": {
|
||||||
|
"name": "项目名称原文", "code": "JSZC-…", "purchaser": "采购人全称", "agency": "代理机构全称",
|
||||||
|
"procurement_method": "公开招标", "legal_regime": "gov_procurement",
|
||||||
|
"budget_total": {"value": "500", "unit": "万元"}, "ceiling_total": {"value": "500", "unit": "万元"},
|
||||||
|
"eval_method": "综合评分法", "accept_consortium": false, "accept_imported": false, "sme_only": false
|
||||||
|
},
|
||||||
|
"lots": [{"lot_no": "1", "budget": {"value": "14", "unit": "万元"}, "ceiling": {"value": "14", "unit": "万元"}}]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 金额按原文的数字和单位写,不换算。预算和最高限价分开;有分包时总额填整个项目的,各包写进 `lots`(没有分包写 `[]`)。
|
||||||
|
- 是 / 否字段:文件写明了才填 true / false,没写填 `null`。
|
||||||
|
- 某个字段的写法在文件里不一致(例如两处预算不同),在 `notes` 里说明。
|
||||||
|
|
||||||
|
## 4. 复核时统一的口径(2026-11-03)
|
||||||
|
|
||||||
|
10 份标注完成后发现各份处理不一致的地方,统一如下:
|
||||||
|
|
||||||
|
- **标注类以句子本身为准**:句子本身带 ★、文件声明了的 *、文件声明了的斜体下划线,一律必标,内容是服务期限、付款、知识产权也照样标(文件自己声明它们是实质性要求)。
|
||||||
|
- **章节标题带 ★ 的**(S01「三、★商务要求」、S07「★售后」、S10「★(一)技术要求」):标题本身记一条;下面不带标注的句子暂记 `optional`,待产品定口径(见 `docs/architecture/extract-eval.md` §5)。
|
||||||
|
- **开标过程、开标记录疑义、质疑投诉程序、中标后事项**都不是 `schedule`;澄清、说明、补正的签章要求暂记 `optional` 的 `signature`。
|
||||||
|
- **裁定补标**:评测后对「正式清单里对不上标注」的条目逐条裁定,确属这一类、只是标注漏了的,补成 `"optional": true, "added_in_review": true` 的单元。这样补标只影响准确率,不会抬高召回率。
|
||||||
|
|
||||||
|
## 5. 文件格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doc": "S01",
|
||||||
|
"annotator": "谁标的",
|
||||||
|
"reviewed": false,
|
||||||
|
"notes": ["这份文件的特殊情况,例如标注规则、表格被拆散的地方"],
|
||||||
|
"marking": {"markers": ["star"], "declared_count": null},
|
||||||
|
"project_info": {…},
|
||||||
|
"lots": [],
|
||||||
|
"tech_param_pages": [41, 43, 45, 48, 50, 52],
|
||||||
|
"items": [
|
||||||
|
{"id": "s1", "cat": "substantive", "basis": "star", "anchors": [{"p": 41, "t": "…"}], "note": "…"},
|
||||||
|
{"id": "q1", "cat": "qualification", "anchors": […], "note": "…"},
|
||||||
|
{"id": "c1", "cat": "scoring", "points": "10", "anchors": […], "note": "…"},
|
||||||
|
{"id": "d1", "cat": "schedule", "kind": "bid_deadline", "anchors": […], "note": "…"},
|
||||||
|
{"id": "t1", "cat": "tech_param", "anchors": […], "note": "…"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`marking.markers` 写文件声明的实质性标注方式(`star` / `asterisk` / `italic_underline`),`declared_count` 是文件写明的 ★ 条款数(没写填 `null`)。
|
||||||
2788
evals/gold/S01.json
Normal file
2788
evals/gold/S01.json
Normal file
File diff suppressed because it is too large
Load Diff
2262
evals/gold/S02.json
Normal file
2262
evals/gold/S02.json
Normal file
File diff suppressed because it is too large
Load Diff
2932
evals/gold/S03.json
Normal file
2932
evals/gold/S03.json
Normal file
File diff suppressed because it is too large
Load Diff
2822
evals/gold/S04.json
Normal file
2822
evals/gold/S04.json
Normal file
File diff suppressed because it is too large
Load Diff
2421
evals/gold/S05.json
Normal file
2421
evals/gold/S05.json
Normal file
File diff suppressed because it is too large
Load Diff
5676
evals/gold/S06.json
Normal file
5676
evals/gold/S06.json
Normal file
File diff suppressed because it is too large
Load Diff
2800
evals/gold/S07.json
Normal file
2800
evals/gold/S07.json
Normal file
File diff suppressed because it is too large
Load Diff
2673
evals/gold/S08.json
Normal file
2673
evals/gold/S08.json
Normal file
File diff suppressed because it is too large
Load Diff
1986
evals/gold/S09.json
Normal file
1986
evals/gold/S09.json
Normal file
File diff suppressed because it is too large
Load Diff
2113
evals/gold/S10.json
Normal file
2113
evals/gold/S10.json
Normal file
File diff suppressed because it is too large
Load Diff
58
evals/prepare.py
Normal file
58
evals/prepare.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""准备标注集评测的输入:S01–S10 转成规范化 PDF,导出带句子编号的全文(给人工标注用)。
|
||||||
|
|
||||||
|
用法(在 server/ 下):
|
||||||
|
../.venv/bin/python ../evals/prepare.py [S01 S02 ...]
|
||||||
|
输出 evals/out/docs/<ID>/:normalized.pdf、sentences.txt(每行 `[p12s3] 句子`,带格式标记和〔上级:…〕)。
|
||||||
|
规范化 PDF 生成一次后复用,抽取评测(run_extract.py)也用它作输入,标注和评测看到的是同一份文字层。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from bid_doctor.checklist import extract
|
||||||
|
from bid_doctor.document import formats
|
||||||
|
from bid_doctor.document.pipeline import convert, structure, text_layer
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent
|
||||||
|
SAMPLES = ROOT.parent / "samples" / "tenders"
|
||||||
|
OUT = ROOT / "out" / "docs"
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare(doc_id: str) -> None:
|
||||||
|
out = OUT / doc_id
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
pdf_path = out / "normalized.pdf"
|
||||||
|
if not pdf_path.exists():
|
||||||
|
src = next(p for p in (SAMPLES / doc_id).iterdir() if p.suffix in (".doc", ".docx", ".pdf"))
|
||||||
|
raw = src.read_bytes()
|
||||||
|
fmt = formats.detect(raw, src.name).format
|
||||||
|
pdf_path.write_bytes(raw if fmt == "pdf" else await convert.to_pdf(raw, fmt))
|
||||||
|
pages = text_layer.extract("D", pdf_path.read_bytes())
|
||||||
|
body = text_layer.body_sentences(pages)
|
||||||
|
parents = structure.parents(body)
|
||||||
|
notes = extract.marking_notes(body)
|
||||||
|
lines = [f"# {doc_id}:{len(pages)} 页,正文句子 {len(body)}", "## 标注规则说明(规则识别)"]
|
||||||
|
lines += [f"- [p{n.sentence.page_no}s{n.sentence.seq}] {n.sentence.text}" for n in notes] or ["(无)"]
|
||||||
|
page = 0
|
||||||
|
for s in body:
|
||||||
|
if s.page_no != page:
|
||||||
|
page = s.page_no
|
||||||
|
lines.append(f"\n=== 第 {page} 页")
|
||||||
|
line = f"[p{s.page_no}s{s.seq}] {s.marked}"
|
||||||
|
if p := parents.get(s.key):
|
||||||
|
line += f"〔上级:{p.text[:40]}〕"
|
||||||
|
lines.append(line)
|
||||||
|
(out / "sentences.txt").write_text("\n".join(lines) + "\n")
|
||||||
|
print(doc_id, len(pages), "页", len(body), "句")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
ids = sys.argv[1:] or [f"S{i:02d}" for i in range(1, 11)]
|
||||||
|
for doc_id in ids:
|
||||||
|
await prepare(doc_id)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
18
evals/run_all.sh
Executable file
18
evals/run_all.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 对 S01–S10 跑全部抽取任务(输入是 prepare.py 生成的规范化 PDF),结果在 evals/out/runs/<名字>/<ID>/。
|
||||||
|
# 用法(仓库根目录):evals/run_all.sh <名字> [run_extract.py 的其他参数]
|
||||||
|
# 换模型:BD_AI_MODELS='{"reasoning": "deepseek-v4-pro"}' evals/run_all.sh pro
|
||||||
|
set -euo pipefail
|
||||||
|
name=$1; shift
|
||||||
|
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
cd "$root/server"
|
||||||
|
for id in S01 S02 S03 S04 S05 S06 S07 S08 S09 S10; do
|
||||||
|
echo "$id"
|
||||||
|
../.venv/bin/python ../evals/run_extract.py "../evals/out/docs/$id/normalized.pdf" "../evals/out/runs/$name/$id" "$@" \
|
||||||
|
> /dev/null 2> "../evals/out/runs/$name.$id.log" &
|
||||||
|
# 同时最多 3 份(每份内部 8 路并发调用模型)
|
||||||
|
# macOS 的 bash 3.2 在 $(…) 子 shell 里看不到后台任务,先写到文件再数
|
||||||
|
while jobs -r > "$root/evals/out/runs/.jobs" && [ "$(wc -l < "$root/evals/out/runs/.jobs")" -ge 3 ]; do sleep 2; done
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
echo done
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
../.venv/bin/python ../evals/run_extract.py <文件> ../evals/out/<名字> [--v extract.tech_param=2 ...] [--only extract.substantive]
|
../.venv/bin/python ../evals/run_extract.py <文件> ../evals/out/<名字> [--v extract.tech_param=2 ...] [--only extract.substantive]
|
||||||
BD_AI_MODELS='{"reasoning": "deepseek-v4-pro"}' 换模型对比。
|
BD_AI_MODELS='{"reasoning": "deepseek-v4-pro"}' 换模型对比。
|
||||||
同一模型 + 同一提示词版本 + 同一输入只调用一次模型(缓存在 <输出目录>/../cache/),只改规则时重放不花钱。
|
同一模型 + 同一提示词版本 + 同一输入只调用一次模型(缓存在 <输出目录>/../cache/),只改规则时重放不花钱。
|
||||||
输出:report.md(结果统计 + 模型输出里不合理模式的审计)、items.json(每条结果和模型原始输出)、normalized.pdf。
|
输出:report.md(结果统计 + 模型输出里不合理模式的审计)、items.json(每条结果和模型原始输出)、info.json(项目基本信息和统计)、normalized.pdf。
|
||||||
真实招标文件和输出都不进 git(evals/out/ 已忽略)。
|
真实招标文件和输出都不进 git(evals/out/ 已忽略)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -24,11 +24,12 @@ from uuid import UUID
|
|||||||
|
|
||||||
from bid_doctor.ai import prompts
|
from bid_doctor.ai import prompts
|
||||||
from bid_doctor.ai import service as ai_service
|
from bid_doctor.ai import service as ai_service
|
||||||
|
from bid_doctor.ai.providers.base import near_limit
|
||||||
from bid_doctor.ai.service import provider
|
from bid_doctor.ai.service import provider
|
||||||
from bid_doctor.checklist import extract
|
from bid_doctor.checklist import extract
|
||||||
from bid_doctor.core.db import TenantContext
|
from bid_doctor.core.db import TenantContext
|
||||||
from bid_doctor.document import formats
|
from bid_doctor.document import formats
|
||||||
from bid_doctor.document.pipeline import convert, structure, text_layer
|
from bid_doctor.document.pipeline import convert, text_layer
|
||||||
|
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("file")
|
ap.add_argument("file")
|
||||||
@@ -39,7 +40,7 @@ args = ap.parse_args()
|
|||||||
|
|
||||||
out = pathlib.Path(args.out)
|
out = pathlib.Path(args.out)
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
cache_dir = out.parent / "cache"
|
cache_dir = pathlib.Path(__file__).resolve().parent / "out" / "cache" # 各次评测共用
|
||||||
cache_dir.mkdir(exist_ok=True)
|
cache_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
versions = dict(v.split("=") for v in args.v)
|
versions = dict(v.split("=") for v in args.v)
|
||||||
@@ -77,8 +78,8 @@ async def cached_run_json(ctx: Any, *, job_type: str, job_id: Any, task: str, pr
|
|||||||
"incomplete": r.incomplete}
|
"incomplete": r.incomplete}
|
||||||
f.write_text(json.dumps(data, ensure_ascii=False))
|
f.write_text(json.dumps(data, ensure_ascii=False))
|
||||||
calls.append({"task": task, "version": version, "user": user, **data})
|
calls.append({"task": task, "version": version, "user": user, **data})
|
||||||
if data.get("incomplete"):
|
if data.get("incomplete") or near_limit(data.get("tokens_out")):
|
||||||
raise ai_service.OutputIncomplete(task) # 和线上一样:输出被截断时由 extract.run 对半重跑
|
raise ai_service.OutputIncomplete(task, data["data"]) # 和线上一样:输出被截断时由 extract.run 对半重跑
|
||||||
return data["data"]
|
return data["data"]
|
||||||
|
|
||||||
|
|
||||||
@@ -92,18 +93,12 @@ async def main() -> None:
|
|||||||
(out / "normalized.pdf").write_bytes(pdf)
|
(out / "normalized.pdf").write_bytes(pdf)
|
||||||
pages = text_layer.extract("D", pdf)
|
pages = text_layer.extract("D", pdf)
|
||||||
body = text_layer.body_sentences(pages)
|
body = text_layer.body_sentences(pages)
|
||||||
notes = extract.marking_notes(body)
|
doc = extract.build_doc(body)
|
||||||
doc = extract.Doc(
|
|
||||||
sentences=body,
|
|
||||||
parents=structure.parents(body),
|
|
||||||
continuations=text_layer.continuations(body),
|
|
||||||
notes=notes,
|
|
||||||
declared={m for n in notes for m in n.markers},
|
|
||||||
)
|
|
||||||
ctx = TenantContext(tenant_id=UUID(int=1), user_id=UUID(int=1))
|
ctx = TenantContext(tenant_id=UUID(int=1), user_id=UUID(int=1))
|
||||||
items, info, run_stats = await extract.run(ctx, UUID(int=2), doc)
|
items, info, run_stats = await extract.run(ctx, UUID(int=2), doc)
|
||||||
|
|
||||||
by_sid = {f"p{s.page_no}s{s.seq}": s for s in body}
|
_sid = extract._sid # noqa: SLF001
|
||||||
|
by_sid = {_sid(s): s for s in body}
|
||||||
# ---------- 结果 ----------
|
# ---------- 结果 ----------
|
||||||
zone = Counter((i.zone, i.pending_reason) for i in items)
|
zone = Counter((i.zone, i.pending_reason) for i in items)
|
||||||
cats = Counter((c, i.zone) for i in items for c in i.categories)
|
cats = Counter((c, i.zone) for i in items for c in i.categories)
|
||||||
@@ -115,6 +110,8 @@ async def main() -> None:
|
|||||||
"categories": i.categories,
|
"categories": i.categories,
|
||||||
"title": i.summary,
|
"title": i.summary,
|
||||||
"importance": i.importance,
|
"importance": i.importance,
|
||||||
|
"importance_basis": i.importance_basis,
|
||||||
|
"basis": i.basis,
|
||||||
"sentences": [f"p{s.page_no}s{s.seq}" for s in i.sentences],
|
"sentences": [f"p{s.page_no}s{s.seq}" for s in i.sentences],
|
||||||
"quote_pages": sorted({s.page_no for s in i.sentences}),
|
"quote_pages": sorted({s.page_no for s in i.sentences}),
|
||||||
"raw": i.ai_values,
|
"raw": i.ai_values,
|
||||||
@@ -122,6 +119,15 @@ async def main() -> None:
|
|||||||
for i in items
|
for i in items
|
||||||
]
|
]
|
||||||
(out / "items.json").write_text(json.dumps(res, ensure_ascii=False, indent=1))
|
(out / "items.json").write_text(json.dumps(res, ensure_ascii=False, indent=1))
|
||||||
|
info_out = {
|
||||||
|
"fields": {k: {"value": v.value, "unit": v.unit, "sentence": _sid(v.sentence)} for k, v in info.fields.items()},
|
||||||
|
"status": info.status,
|
||||||
|
"lots": [{k: v for k, v in lot.items() if k != "sentence"} for lot in info.lots],
|
||||||
|
"stats": run_stats,
|
||||||
|
"tokens": [sum(c.get("tokens_in") or 0 for c in calls), sum(c.get("tokens_out") or 0 for c in calls)],
|
||||||
|
"models": sorted({c.get("model") or "" for c in calls}),
|
||||||
|
}
|
||||||
|
(out / "info.json").write_text(json.dumps(info_out, ensure_ascii=False, indent=1))
|
||||||
|
|
||||||
# ---------- 模型原始输出的审计 ----------
|
# ---------- 模型原始输出的审计 ----------
|
||||||
audit: dict[str, Counter[str]] = defaultdict(Counter)
|
audit: dict[str, Counter[str]] = defaultdict(Counter)
|
||||||
|
|||||||
300
evals/score.py
Normal file
300
evals/score.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""标注集评测:用 evals/gold/ 的标注给一次抽取(run_all.sh 的输出)打分。
|
||||||
|
|
||||||
|
用法(在 server/ 下):../.venv/bin/python ../evals/score.py <运行名> [S01 ...]
|
||||||
|
输出 evals/out/score/<运行名>/:summary.md(各类召回率、准确率、项目信息正确率)、<ID>.md(漏抽、多抽、
|
||||||
|
被误拦进待确认区的条目,给人工裁定用)、score.json。
|
||||||
|
|
||||||
|
口径:
|
||||||
|
- 一条标注被「召回」= 有一个对应类别的清单项,出处句子和标注的句子有交集。正式清单算一次,正式 + 待确认再算一次
|
||||||
|
(待确认区用户也看得到,只是要多点一下)。
|
||||||
|
- 「准确」= 正式清单里这一类的条目,出处和这一类的某条标注(含 optional)有交集。
|
||||||
|
- 技术参数只在抽样页上算:标注是抽样页上的全部要求,条目只算出处落在抽样页上的。
|
||||||
|
- optional 的标注不算进召回的分母,抽到了算对。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import gold
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent
|
||||||
|
CAT = {
|
||||||
|
"substantive": "invalidity",
|
||||||
|
"qualification": "qualification",
|
||||||
|
"scoring": "scoring",
|
||||||
|
"schedule": "schedule",
|
||||||
|
"tech_param": "tech_param",
|
||||||
|
}
|
||||||
|
NAMES = {
|
||||||
|
"substantive": "实质性条款",
|
||||||
|
"substantive/marked": " 标注类(★ / * / 斜体下划线)",
|
||||||
|
"substantive/explicit_invalid": " 写明无效类",
|
||||||
|
"qualification": "资格条件",
|
||||||
|
"scoring": "评分项",
|
||||||
|
"schedule": "日期与递交",
|
||||||
|
"tech_param": "技术参数(抽样页)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def subgroup(u: gold.Unit) -> str | None:
|
||||||
|
if u.cat != "substantive":
|
||||||
|
return None
|
||||||
|
return "substantive/explicit_invalid" if u.raw.get("basis") == "explicit_invalid" else "substantive/marked"
|
||||||
|
|
||||||
|
|
||||||
|
def pct(a: int, b: int) -> str:
|
||||||
|
return f"{a / b:.1%}({a}/{b})" if b else "—"
|
||||||
|
|
||||||
|
|
||||||
|
def text_of(doc_id: str, sids: list[str] | set[str], limit: int = 80) -> str:
|
||||||
|
by = {gold.sid(s): s for s in gold.sentences(doc_id)}
|
||||||
|
return " / ".join(by[k].text[:limit] for k in sorted(sids, key=_order) if k in by)
|
||||||
|
|
||||||
|
|
||||||
|
def _order(k: str) -> tuple[int, int]:
|
||||||
|
m = re.match(r"p(\d+)s(\d+)", k)
|
||||||
|
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def score_doc(run: str, doc_id: str) -> tuple[dict[str, Any], list[str]]:
|
||||||
|
out_dir = ROOT / "out" / "runs" / run / doc_id
|
||||||
|
items = json.loads((out_dir / "items.json").read_text())
|
||||||
|
info = json.loads((out_dir / "info.json").read_text())
|
||||||
|
g = gold.load(doc_id)
|
||||||
|
units, problems = gold.units(doc_id)
|
||||||
|
pages = set(g.get("tech_param_pages") or [])
|
||||||
|
star_scope = star_sections(doc_id, g)
|
||||||
|
page_of = lambda k: _order(k)[0] # noqa: E731
|
||||||
|
|
||||||
|
res: dict[str, Any] = {"problems": problems}
|
||||||
|
detail: list[str] = [f"# {doc_id} · {run}", ""]
|
||||||
|
if problems:
|
||||||
|
detail += ["## 标注定位问题", *(f"- {p}" for p in problems), ""]
|
||||||
|
|
||||||
|
def in_scope(cat: str, it: dict[str, Any]) -> bool:
|
||||||
|
return cat != "tech_param" or any(page_of(k) in pages for k in it["sentences"])
|
||||||
|
|
||||||
|
qual_sids = set().union(*(u.sids for u in units if u.cat == "qualification"))
|
||||||
|
for cat, pcat in CAT.items():
|
||||||
|
cu = [u for u in units if u.cat == cat]
|
||||||
|
preds = [it for it in items if pcat in it["categories"] and in_scope(cat, it)]
|
||||||
|
formal = [it for it in preds if it["zone"] == "formal"]
|
||||||
|
# 待确认区:待确认的条目,以及正式条目上「待确认的类别」(extract.add_pending_categories)
|
||||||
|
pending = [it for it in preds if it["zone"] != "formal"] + [
|
||||||
|
it
|
||||||
|
for it in items
|
||||||
|
if it["zone"] == "formal"
|
||||||
|
and pcat not in it["categories"]
|
||||||
|
and pcat in ((it.get("raw") or {}).get("pending_categories") or {})
|
||||||
|
and in_scope(cat, it)
|
||||||
|
]
|
||||||
|
gold_sids = set().union(*(u.sids for u in cu)) if cu else set()
|
||||||
|
|
||||||
|
def hit(u: gold.Unit, pool: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [it for it in pool if u.sids & set(it["sentences"])]
|
||||||
|
|
||||||
|
groups: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||||
|
missed, pend_only, dups = [], [], 0
|
||||||
|
for u in cu:
|
||||||
|
if u.optional:
|
||||||
|
continue
|
||||||
|
f, p = hit(u, formal), hit(u, pending)
|
||||||
|
other = [it for it in items if u.sids & set(it["sentences"])]
|
||||||
|
for key in filter(None, (cat, subgroup(u))):
|
||||||
|
groups[key]["gold"] += 1
|
||||||
|
groups[key]["formal"] += bool(f)
|
||||||
|
groups[key]["any"] += bool(f or p)
|
||||||
|
groups[key]["anycat"] += bool(other)
|
||||||
|
dups += len(f) > 1
|
||||||
|
if not f and not p:
|
||||||
|
missed.append((u, other))
|
||||||
|
elif not f:
|
||||||
|
pend_only.append((u, p))
|
||||||
|
def ok(it: dict[str, Any]) -> bool:
|
||||||
|
if gold_sids & set(it["sentences"]):
|
||||||
|
return True
|
||||||
|
if cat != "substantive":
|
||||||
|
return False
|
||||||
|
if set(it["sentences"]) <= star_scope: # 口径 Q1:★ 节里的条目(节内没有逐条标注)
|
||||||
|
return True
|
||||||
|
# 口径 Q4:政府采购项目的资格条件按法规(87 号令第六十三条)带「无效」类别
|
||||||
|
return it.get("importance_basis") == "statute" and bool(qual_sids & set(it["sentences"]))
|
||||||
|
|
||||||
|
right = [it for it in formal if ok(it)]
|
||||||
|
wrong = [it for it in formal if not ok(it)]
|
||||||
|
good_pending = [it for it in pending if gold_sids & set(it["sentences"])]
|
||||||
|
for key in [cat] + ([k for k in groups if k.startswith(cat + "/")] if cat == "substantive" else []):
|
||||||
|
res[key] = dict(groups[key])
|
||||||
|
res[cat].update(
|
||||||
|
pred_formal=len(formal),
|
||||||
|
formal_right=len(right),
|
||||||
|
pending=len(pending),
|
||||||
|
pending_right=len(good_pending),
|
||||||
|
dups=dups,
|
||||||
|
)
|
||||||
|
|
||||||
|
detail.append(f"## {NAMES[cat]}")
|
||||||
|
if missed:
|
||||||
|
detail.append(f"### 漏抽({len(missed)})")
|
||||||
|
for u, other in missed:
|
||||||
|
where = (
|
||||||
|
";被别的类别抽到:" + ",".join(sorted({c for it in other for c in it["categories"]}))
|
||||||
|
if other
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
detail.append(
|
||||||
|
f"- **{u.id}** {u.raw.get('basis') or u.raw.get('kind') or ''} {u.raw.get('note', '')}{where}"
|
||||||
|
)
|
||||||
|
detail.append(f" - {text_of(doc_id, u.sids)}")
|
||||||
|
if pend_only:
|
||||||
|
detail.append(f"### 只在待确认区({len(pend_only)})")
|
||||||
|
for u, p in pend_only:
|
||||||
|
reasons = ",".join(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
r
|
||||||
|
for it in p
|
||||||
|
for r in (it.get("raw", {}) or {}).get("failures", []) or [it.get("reason") or ""]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
detail.append(f"- **{u.id}** {u.raw.get('note', '')}〔{reasons}〕")
|
||||||
|
if wrong:
|
||||||
|
detail.append(f"### 正式清单里对不上标注的({len(wrong)})")
|
||||||
|
for it in wrong:
|
||||||
|
detail.append(f"- [{it['task']}] {it['title']}|{text_of(doc_id, it['sentences'], 60)}")
|
||||||
|
detail.append("")
|
||||||
|
|
||||||
|
res["info"] = score_info(g, info)
|
||||||
|
detail.append("## 项目信息")
|
||||||
|
for k, (ok, want, got) in res["info"].items():
|
||||||
|
detail.append(f"- {'✓' if ok else '✗'} {k}:标注 {want},抽到 {got}")
|
||||||
|
if g.get("marking", {}).get("declared_count"):
|
||||||
|
n = sum(1 for u in units if u.cat == "substantive" and u.raw.get("basis") == "star")
|
||||||
|
res["declared"] = [g["marking"]["declared_count"], n]
|
||||||
|
return res, detail
|
||||||
|
|
||||||
|
|
||||||
|
def star_sections(doc_id: str, g: dict[str, Any]) -> set[str]:
|
||||||
|
"""标注集里记的 ★ 节范围(marking.star_sections,from 含、to 不含)里的句子编号。"""
|
||||||
|
order = [gold.sid(s) for s in gold.sentences(doc_id)]
|
||||||
|
out: set[str] = set()
|
||||||
|
for sec in (g.get("marking") or {}).get("star_sections") or []:
|
||||||
|
a, _ = gold.locate(doc_id, sec["from"]["p"], sec["from"]["t"])
|
||||||
|
b, _ = gold.locate(doc_id, sec["to"]["p"], sec["to"]["t"])
|
||||||
|
if a and b:
|
||||||
|
out.update(order[order.index(a[0]) : order.index(b[0])])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def yuan(v: Any, unit: Any) -> Decimal | None:
|
||||||
|
try:
|
||||||
|
n = Decimal(str(v).replace(",", "").replace(",", ""))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
return None
|
||||||
|
return n * 10000 if unit and "万" in str(unit) else n
|
||||||
|
|
||||||
|
|
||||||
|
def score_info(g: dict[str, Any], info: dict[str, Any]) -> dict[str, tuple[bool | None, Any, Any]]:
|
||||||
|
"""每个字段:(对不对, 标注值, 抽到的值)。标注是 null 的,抽到「没写」才算对;抽到 needs_review 算没答出来。"""
|
||||||
|
want_all = g.get("project_info") or {}
|
||||||
|
out: dict[str, tuple[bool | None, Any, Any]] = {}
|
||||||
|
for k in gold.INFO_KEYS:
|
||||||
|
want = want_all.get(k)
|
||||||
|
f = info["fields"].get(k)
|
||||||
|
status = info["status"].get(k, {}).get("status")
|
||||||
|
got: Any = f["value"] if f else f"〔{status}〕"
|
||||||
|
if f is None:
|
||||||
|
ok = want is None and status == "absent"
|
||||||
|
elif want is None:
|
||||||
|
ok = False
|
||||||
|
elif k in ("budget_total", "ceiling_total"):
|
||||||
|
w = yuan(want.get("value"), want.get("unit")) if isinstance(want, dict) else yuan(want, None)
|
||||||
|
ok = w is not None and w == yuan(f["value"], f.get("unit"))
|
||||||
|
got = f"{f['value']}{f.get('unit') or ''}"
|
||||||
|
elif isinstance(want, bool):
|
||||||
|
ok = f["value"] is want
|
||||||
|
else:
|
||||||
|
a, b = gold.norm(str(want)), gold.norm(str(f["value"]))
|
||||||
|
ok = a == b if k in ("code", "legal_regime") else bool(a and b and (a in b or b in a))
|
||||||
|
if isinstance(want, dict):
|
||||||
|
want = f"{want.get('value')}{want.get('unit') or ''}"
|
||||||
|
out[k] = (ok, want, got)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
run = sys.argv[1]
|
||||||
|
ids = sys.argv[2:] or [p.stem for p in sorted(gold.GOLD.glob("S*.json"))]
|
||||||
|
dest = ROOT / "out" / "score" / run
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
all_res: dict[str, Any] = {}
|
||||||
|
for doc_id in ids:
|
||||||
|
if not (ROOT / "out" / "runs" / run / doc_id / "items.json").exists():
|
||||||
|
print(doc_id, "没有抽取结果,跳过")
|
||||||
|
continue
|
||||||
|
res, detail = score_doc(run, doc_id)
|
||||||
|
all_res[doc_id] = res
|
||||||
|
(dest / f"{doc_id}.md").write_text("\n".join(detail) + "\n")
|
||||||
|
(dest / "score.json").write_text(json.dumps(all_res, ensure_ascii=False, indent=1, default=str))
|
||||||
|
|
||||||
|
lines = [f"# 标注集评测 · {run}", "", f"文件:{', '.join(all_res)}", ""]
|
||||||
|
lines += [
|
||||||
|
"| 类别 | 标注 | 召回(正式清单) | 召回(含待确认) | 准确率(正式清单) | 待确认里是好结果 | 重复条目 |",
|
||||||
|
"|---|---|---|---|---|---|---|",
|
||||||
|
]
|
||||||
|
for key in NAMES:
|
||||||
|
tot: dict[str, int] = defaultdict(int)
|
||||||
|
for r in all_res.values():
|
||||||
|
for k, v in (r.get(key) or {}).items():
|
||||||
|
tot[k] += v
|
||||||
|
prec = pct(tot["formal_right"], tot["pred_formal"]) if "/" not in key else ""
|
||||||
|
pend = pct(tot["pending_right"], tot["pending"]) if "/" not in key else ""
|
||||||
|
dups = str(tot["dups"]) if "/" not in key else ""
|
||||||
|
lines.append(
|
||||||
|
f"| {NAMES[key]} | {tot['gold']} | {pct(tot['formal'], tot['gold'])} | {pct(tot['any'], tot['gold'])} | {prec} | {pend} | {dups} |"
|
||||||
|
)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"## 按文件",
|
||||||
|
"",
|
||||||
|
"| 文件 | " + " | ".join(NAMES[k].strip(" ") for k in NAMES) + " |",
|
||||||
|
"|---|" + "---|" * len(NAMES),
|
||||||
|
]
|
||||||
|
for doc_id, r in all_res.items():
|
||||||
|
cells = []
|
||||||
|
for key in NAMES:
|
||||||
|
v = r.get(key) or {}
|
||||||
|
cell = f"{v.get('formal', 0)}/{v.get('any', 0)}/{v.get('gold', 0)}"
|
||||||
|
if "/" not in key:
|
||||||
|
cell += f" · {v.get('formal_right', 0)}/{v.get('pred_formal', 0)}"
|
||||||
|
cells.append(cell)
|
||||||
|
lines.append(f"| {doc_id} | " + " | ".join(cells) + " |")
|
||||||
|
lines += ["", "格子里:正式召回/含待确认召回/标注数 · 正式条目对上标注/正式条目数", ""]
|
||||||
|
lines += ["## 项目信息", "", "| 字段 | 正确 | 错的 |", "|---|---|---|"]
|
||||||
|
for k in gold.INFO_KEYS:
|
||||||
|
oks = [(d, r["info"][k]) for d, r in all_res.items()]
|
||||||
|
bad = [f"{d}(标 {w},抽 {g_})" for d, (ok, w, g_) in oks if not ok]
|
||||||
|
lines.append(f"| {k} | {sum(1 for _, (ok, _, _) in oks if ok)}/{len(oks)} | {';'.join(bad)} |")
|
||||||
|
declared = {d: r["declared"] for d, r in all_res.items() if "declared" in r}
|
||||||
|
if declared:
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"文件写明的 ★ 条款数 / 标注的 ★ 单元数:" + ",".join(f"{d} {a}/{b}" for d, (a, b) in declared.items()),
|
||||||
|
]
|
||||||
|
problems = {d: r["problems"] for d, r in all_res.items() if r["problems"]}
|
||||||
|
if problems:
|
||||||
|
lines += ["", "## 标注定位问题", *(f"- {d}:{len(p)} 处" for d, p in problems.items())]
|
||||||
|
(dest / "summary.md").write_text("\n".join(lines) + "\n")
|
||||||
|
print("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user