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:
@@ -1,5 +1,24 @@
|
||||
# 评测
|
||||
|
||||
- `run_extract.py`:离线跑一份招标文件的全部抽取任务,统计结果,并审计模型输出里的不合理模式(引文太短、同一句被拆成多条、编号和引文不是同一句、声称有标注但原文没有……)。改提示词、改规则、换模型前后各跑一次对比。用法见文件头。
|
||||
- 输出放在 `evals/out/`(不进 git):里面有真实招标文件的内容。
|
||||
- TODO:S01–S10 的人工标注集和召回率、准确率计算(`prompts/README.md` 要求的回归评测)。
|
||||
- 输出放在 `evals/out/`(不进 git):里面有真实招标文件的内容。模型输出缓存在 `evals/out/cache/`,同一模型 + 提示词版本 + 输入只调用一次,只改规则时重放不花钱。
|
||||
|
||||
## 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]
|
||||
BD_AI_MODELS='{"reasoning": "deepseek-v4-pro"}' 换模型对比。
|
||||
同一模型 + 同一提示词版本 + 同一输入只调用一次模型(缓存在 <输出目录>/../cache/),只改规则时重放不花钱。
|
||||
输出:report.md(结果统计 + 模型输出里不合理模式的审计)、items.json(每条结果和模型原始输出)、normalized.pdf。
|
||||
输出:report.md(结果统计 + 模型输出里不合理模式的审计)、items.json(每条结果和模型原始输出)、info.json(项目基本信息和统计)、normalized.pdf。
|
||||
真实招标文件和输出都不进 git(evals/out/ 已忽略)。
|
||||
"""
|
||||
|
||||
@@ -24,11 +24,12 @@ from uuid import UUID
|
||||
|
||||
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 provider
|
||||
from bid_doctor.checklist import extract
|
||||
from bid_doctor.core.db import TenantContext
|
||||
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.add_argument("file")
|
||||
@@ -39,7 +40,7 @@ args = ap.parse_args()
|
||||
|
||||
out = pathlib.Path(args.out)
|
||||
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)
|
||||
|
||||
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}
|
||||
f.write_text(json.dumps(data, ensure_ascii=False))
|
||||
calls.append({"task": task, "version": version, "user": user, **data})
|
||||
if data.get("incomplete"):
|
||||
raise ai_service.OutputIncomplete(task) # 和线上一样:输出被截断时由 extract.run 对半重跑
|
||||
if data.get("incomplete") or near_limit(data.get("tokens_out")):
|
||||
raise ai_service.OutputIncomplete(task, data["data"]) # 和线上一样:输出被截断时由 extract.run 对半重跑
|
||||
return data["data"]
|
||||
|
||||
|
||||
@@ -92,18 +93,12 @@ async def main() -> None:
|
||||
(out / "normalized.pdf").write_bytes(pdf)
|
||||
pages = text_layer.extract("D", pdf)
|
||||
body = text_layer.body_sentences(pages)
|
||||
notes = extract.marking_notes(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},
|
||||
)
|
||||
doc = extract.build_doc(body)
|
||||
ctx = TenantContext(tenant_id=UUID(int=1), user_id=UUID(int=1))
|
||||
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)
|
||||
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,
|
||||
"title": i.summary,
|
||||
"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],
|
||||
"quote_pages": sorted({s.page_no for s in i.sentences}),
|
||||
"raw": i.ai_values,
|
||||
@@ -122,6 +119,15 @@ async def main() -> None:
|
||||
for i in items
|
||||
]
|
||||
(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)
|
||||
|
||||
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