读标操作(FR-M2-07、08) - 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、 采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、 替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本, 内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读 - 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖 - 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除 (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、 选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示 待确认审计(架构 §13 SK11) - 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句 (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文 数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记 - 不放宽:引文最短 8 个字、编号必须对、证据全部通过 - 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2; 引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本 - BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py - 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
9.3 KiB
Python
210 lines
9.3 KiB
Python
"""离线抽取评测:对一份招标文件跑全部抽取任务,缓存模型原始输出,统计结果和模型输出里的不合理模式。
|
||
|
||
用法(在 server/ 下,模型配置读根目录 .env):
|
||
../.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。
|
||
真实招标文件和输出都不进 git(evals/out/ 已忽略)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import dataclasses
|
||
import hashlib
|
||
import json
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from typing import Any
|
||
from uuid import UUID
|
||
|
||
from bid_doctor.ai import prompts
|
||
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
|
||
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("file")
|
||
ap.add_argument("out")
|
||
ap.add_argument("--v", action="append", default=[], help="task=version")
|
||
ap.add_argument("--only", action="append", default=[])
|
||
args = ap.parse_args()
|
||
|
||
out = pathlib.Path(args.out)
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
cache_dir = out.parent / "cache"
|
||
cache_dir.mkdir(exist_ok=True)
|
||
|
||
versions = dict(v.split("=") for v in args.v)
|
||
extract.TASKS[:] = [
|
||
dataclasses.replace(t, prompt_version=int(versions.get(t.name, t.prompt_version)))
|
||
for t in extract.TASKS
|
||
if not args.only or t.name in args.only
|
||
]
|
||
calls: list[dict[str, Any]] = []
|
||
|
||
|
||
async def cached_run_json(ctx: Any, *, job_type: str, job_id: Any, task: str, prompt_id: str, version: int, user: str) -> dict[str, Any]:
|
||
del ctx, job_type, job_id
|
||
p = prompts.get(prompt_id, version)
|
||
from bid_doctor.core.config import get_settings
|
||
|
||
s = get_settings()
|
||
model = s.ai_models.get(p.capability) or s.ai_model
|
||
tag = "" if model == "deepseek-chat" else f"{model}\n" # 旧缓存没带模型名:deepseek-chat 保持原来的键
|
||
key = hashlib.sha256(f"{tag}{prompt_id}\n{version}\n{p.body}\n{user}".encode()).hexdigest()[:32]
|
||
f = cache_dir / f"{key}.json"
|
||
if f.exists():
|
||
data = json.loads(f.read_text())
|
||
else:
|
||
for attempt in range(3):
|
||
try:
|
||
r = await provider().complete_json(p.body, user, p.capability, task)
|
||
break
|
||
except Exception as e: # noqa: BLE001
|
||
if attempt == 2:
|
||
raise
|
||
print("retry", task, e, file=sys.stderr)
|
||
await asyncio.sleep(3)
|
||
data = {"data": r.data, "model": r.model, "tokens_in": r.tokens_in, "tokens_out": r.tokens_out}
|
||
f.write_text(json.dumps(data, ensure_ascii=False))
|
||
calls.append({"task": task, "version": version, "user": user, **data})
|
||
return data["data"]
|
||
|
||
|
||
extract.ai.run_json = cached_run_json # type: ignore[attr-defined]
|
||
|
||
|
||
async def main() -> None:
|
||
raw = pathlib.Path(args.file).read_bytes()
|
||
det = formats.detect(raw, pathlib.Path(args.file).name)
|
||
pdf = raw if det.format == "pdf" else await convert.to_pdf(raw, det.format)
|
||
(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},
|
||
)
|
||
ctx = TenantContext(tenant_id=UUID(int=1), user_id=UUID(int=1))
|
||
items, info = await extract.run(ctx, UUID(int=2), doc)
|
||
|
||
by_sid = {f"p{s.page_no}s{s.seq}": 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)
|
||
res = [
|
||
{
|
||
"task": i.task,
|
||
"zone": i.zone,
|
||
"reason": i.pending_reason,
|
||
"categories": i.categories,
|
||
"title": i.summary,
|
||
"importance": i.importance,
|
||
"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,
|
||
}
|
||
for i in items
|
||
]
|
||
(out / "items.json").write_text(json.dumps(res, ensure_ascii=False, indent=1))
|
||
|
||
# ---------- 模型原始输出的审计 ----------
|
||
audit: dict[str, Counter[str]] = defaultdict(Counter)
|
||
samples: dict[str, list[str]] = defaultdict(list)
|
||
per_sentence: dict[str, Counter[str]] = defaultdict(Counter)
|
||
|
||
def note(task: str, kind: str, example: str) -> None:
|
||
audit[task][kind] += 1
|
||
if len(samples[f"{task}|{kind}"]) < 4:
|
||
samples[f"{task}|{kind}"].append(example)
|
||
|
||
for c in calls:
|
||
task = c["task"]
|
||
chunk_ids = set(re.findall(r"^\[(p\d+s\d+)\]", c["user"], re.M))
|
||
for it in (c["data"] or {}).get("items") or []:
|
||
if not isinstance(it, dict):
|
||
continue
|
||
audit[task]["items"] += 1
|
||
evs = [e for e in it.get("evidence") or [] if isinstance(e, dict)]
|
||
if not evs:
|
||
note(task, "没有证据", json.dumps(it, ensure_ascii=False)[:120])
|
||
if len(evs) > 3:
|
||
note(task, "证据超过 3 段", f"{len(evs)} 段:{(it.get('summary') or it.get('requirement') or it.get('factor'))}")
|
||
for e in evs:
|
||
sid, q = str(e.get("sid")), extract.norm(str(e.get("quote", "")))
|
||
per_sentence[task][sid] += 1
|
||
if sid not in chunk_ids:
|
||
note(task, "编号不在本分块", f"{sid} {q[:30]}")
|
||
continue
|
||
s = by_sid.get(sid)
|
||
if s is None:
|
||
continue
|
||
t = extract.norm(s.text)
|
||
if q not in t:
|
||
joined = "".join(extract.norm(x.text) for x in body[body.index(s): body.index(s) + 3])
|
||
if q and q in joined:
|
||
note(task, "引文跨句", f"{sid} {q[:40]}")
|
||
elif any(q and q in extract.norm(x.text) for x in body):
|
||
note(task, "编号和引文不是同一句", f"{sid} 引文「{q[:30]}」原句「{t[:30]}」")
|
||
else:
|
||
note(task, "引文不是原文", f"{sid} 引文「{q[:30]}」原句「{t[:30]}」")
|
||
elif len(q) < min(8, len(t)):
|
||
note(task, "引文太短", f"{sid} 「{q}」← {t[:40]}")
|
||
elif len(t) >= 20 and len(q) / len(t) < 0.3:
|
||
note(task, "引文只摘了原句一小段(<30%)", f"{sid} 「{q[:30]}」← {t[:40]}")
|
||
if s.location == "header_footer":
|
||
note(task, "引了页眉页脚", sid)
|
||
if s.key in doc.note_keys:
|
||
note(task, "引了标注规则说明", sid)
|
||
basis = it.get("basis")
|
||
if basis in ("italic_underline", "star", "asterisk"):
|
||
ok = any((s := by_sid.get(str(e.get("sid")))) and extract.is_marked(s, doc.declared) == basis for e in evs)
|
||
if not ok:
|
||
marked = "".join(by_sid[str(e.get("sid"))].marked[:50] for e in evs[:1] if str(e.get("sid")) in by_sid)
|
||
note(task, f"声称 {basis} 但原文没有该标注", marked)
|
||
for task, cnt in per_sentence.items():
|
||
multi = {sid: n for sid, n in cnt.items() if n > 1}
|
||
if multi:
|
||
audit[task]["同一句被拆成多条"] = len(multi)
|
||
top = sorted(multi.items(), key=lambda kv: -kv[1])[:3]
|
||
samples[f"{task}|同一句被拆成多条"] = [f"{sid} ×{n}:{by_sid[sid].text[:40]}" for sid, n in top if sid in by_sid]
|
||
|
||
tokens = sum((c.get("tokens_in") or 0) for c in calls), sum((c.get("tokens_out") or 0) for c in calls)
|
||
lines = [
|
||
f"# {pathlib.Path(args.file).name}",
|
||
f"页数 {len(pages)},正文句子 {len(body)},分块 {len(extract.chunks(body, doc.continuations))},模型调用 {len(calls)} 次,tokens {tokens[0]} / {tokens[1]}",
|
||
f"版本:{ {t.name: t.prompt_version for t in extract.TASKS} }",
|
||
"",
|
||
"## 结果",
|
||
f"条目 {len(items)}:" + ",".join(f"{z}/{r or '-'} {n}" for (z, r), n in zone.most_common()),
|
||
"按类别:" + ",".join(f"{c}/{z}:{n}" for (c, z), n in sorted(cats.items())),
|
||
"项目信息:" + ",".join(f"{k}={getattr(v, 'value', v)}" for k, v in info.fields.items())[:600],
|
||
"",
|
||
"## 模型输出审计",
|
||
]
|
||
for task, cnt in audit.items():
|
||
lines.append(f"### {task}({cnt['items']} 条)")
|
||
for k, n in cnt.most_common():
|
||
if k == "items":
|
||
continue
|
||
lines.append(f"- {k}:{n}")
|
||
for ex in samples.get(f"{task}|{k}", []):
|
||
lines.append(f" - {ex}")
|
||
report = "\n".join(lines)
|
||
(out / "report.md").write_text(report)
|
||
print(report)
|
||
|
||
|
||
asyncio.run(main())
|