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:
YANG JIANKUAN
2026-11-03 23:19:30 +08:00
parent f98fd51128
commit 602703c47c
21 changed files with 29427 additions and 18 deletions

View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

2262
evals/gold/S02.json Normal file

File diff suppressed because it is too large Load Diff

2932
evals/gold/S03.json Normal file

File diff suppressed because it is too large Load Diff

2822
evals/gold/S04.json Normal file

File diff suppressed because it is too large Load Diff

2421
evals/gold/S05.json Normal file

File diff suppressed because it is too large Load Diff

5676
evals/gold/S06.json Normal file

File diff suppressed because it is too large Load Diff

2800
evals/gold/S07.json Normal file

File diff suppressed because it is too large Load Diff

2673
evals/gold/S08.json Normal file

File diff suppressed because it is too large Load Diff

1986
evals/gold/S09.json Normal file

File diff suppressed because it is too large Load Diff

2113
evals/gold/S10.json Normal file

File diff suppressed because it is too large Load Diff

58
evals/prepare.py Normal file
View 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
View 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

View File

@@ -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
View 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()