Files
bid-doctor/evals/check/score.py
YANG JIANKUAN 47c6a1258e feat(check): 文件要在、扫描件兜底(C10);响应方式存进清单并可改(C12)
- check/presence.py:「提供《授权委托书》」「须提交投标函」这类要求按标题里有没有这个文件名判断,不调模型。
- 扫描件兜底:要附证明材料的要求没找到、投标文件里有扫描件页时,改为提示级「可能在扫描件里,请人工核对」。
- 响应方式解析时识别、带依据句子存进 checklist.response_modes(迁移 0008);清单页显示、可改(写审计,
  重新解析不覆盖人工改过的);查标用存下来的。新接口 PUT /projects/{id}/checklist/response-modes/{category}。
- check_item_result.layer 加 presence(迁移 0007);契约同步默认接受的两种结论。

开发集 17 份:规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32)(docs/architecture/check-eval.md §5)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:53:45 +08:00

206 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""查标评分:对照响应清单给一次离线检查打分(evals/check/GUIDELINES.md §7)。
cd server && ../.venv/bin/python ../evals/check/score.py <投标文件> [--run base] [--include-unreviewed]
- 清单项 → 标注单元:清单项的招标侧句子和单元的句子有重叠就对上(和读标评分同一口径);
- 按单元去重:同一要求在清单里有几条,只要一条报出就算召回,几条都报不算误报;
- 问题类型按类型族比(D01 / D02 / D09 / D11 / D13 → unresponded,D03 / D07 → suspected_unmet,……);
证明材料类(D02 / D11 / D13)报成「可能在扫描件里,请人工核对」也算标出来了(C10),另外单独计数;
- 只算复核过的问题(决策 E1);--include-unreviewed 时也算没复核的,报告里标明;
- 读标没抽到的问题单列「读标漏抽」:不算查标漏报,但计入端到端召回;
- 对不上任何标注单元的问题单列「无法对照」。
输出:evals/out/check/<投标文件>/runs/<运行>/score.json、score.md。
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
from collections import Counter, defaultdict
from typing import Any
EVALS = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(EVALS))
import gold # noqa: E402
OUT = EVALS / "out" / "check"
FAMILY = {
"D01": {"unresponded"},
"D02": {"unresponded", "manual_review"},
"D09": {"unresponded"},
"D11": {"unresponded", "manual_review"},
"D13": {"unresponded", "manual_review"},
"D16": {"unresponded"},
"D03": {"suspected_unmet"},
"D07": {"suspected_unmet"},
"D08": {"suspected_unmet"},
"D05": {"negative_deviation", "suspected_unmet"},
"D06": {"negative_deviation"},
"D10": {"negative_deviation"},
"D12": {"qualification"},
"D14": {"qualification"},
"D15": {"qualification", "unresponded"},
}
FATAL_CATS = {"substantive", "qualification"}
def pct(a: int, b: int) -> str:
return f"{a}/{b}({a / b:.0%})" if b else "—"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("bid")
ap.add_argument("--run", default="base")
ap.add_argument("--include-unreviewed", action="store_true")
a = ap.parse_args()
d = OUT / a.bid
manifest = json.loads((d / "manifest.json").read_text())
res = json.loads((d / "runs" / a.run / "results.json").read_text())
units, _ = gold.units(manifest["tender"])
sid_units: dict[str, set[str]] = defaultdict(set)
for u in units:
for s in u.sids:
sid_units[s].add(u.id)
# 标注集里同一条要求有时记了两个单元(两个类别各一条,或表格一行拆成几条):句子有重叠的单元合成一组,按组计
parent = {u.id: u.id for u in units}
def find(x: str) -> str:
while parent[x] != x:
x = parent[x]
return x
for ids in sid_units.values():
first, *rest = sorted(ids)
for o in rest:
parent[find(o)] = find(first)
group = {u.id: find(u.id) for u in units}
per_unit: dict[str, list[dict[str, Any]]] = defaultdict(list)
unmapped: list[dict[str, Any]] = []
for it in res["items"]:
hit = {u for s in it["sentences"] for u in sid_units.get(s, ())}
for u in hit:
per_unit[u].append(it)
if not hit and it["finding"]:
unmapped.append(it)
rec: Counter[str] = Counter()
rec_total: Counter[str] = Counter()
sev_ok = sev_n = 0
misses: list[str] = []
extraction_miss: list[str] = []
fp: list[str] = []
fp_by_trap: Counter[str] = Counter()
ok_units = trap_units = 0
other_findings = 0
seen_groups: set[str] = set()
for uid, e in sorted(manifest["units"].items(), key=lambda kv: kv[1].get("expect") != "defect"):
g = group.get(uid, uid)
if g in seen_groups:
continue # 同组的单元只算一次(挂了问题的那个优先)
seen_groups.add(g)
items = [it for x in manifest["units"] if group.get(x, x) == g for it in per_unit.get(x, [])]
found = [it for it in items if it["finding"]]
if e["expect"] == "defect":
if not e.get("reviewed") and not a.include_unreviewed:
continue
keys = [e["defect"], f"粒度:{e['granularity']}", "带标志词" if e.get("worded") else "不带标志词"]
fatal = e["cat"] in FATAL_CATS
if fatal:
keys.append("致命级")
for k in keys:
rec_total[k] += 1
rec_total["端到端"] += 1
if not items:
extraction_miss.append(f"{uid}({e['defect']},{e['cat']})")
continue
rec_total["查标"] += 1
good = [it for it in found if it["finding"]["type"] in FAMILY.get(e["defect"], set())]
if good:
for k in [*keys, "端到端", "查标"]:
rec[k] += 1
if all(it["finding"]["type"] == "manual_review" for it in good):
rec["其中只是扫描件待人工核对"] += 1
if fatal:
sev_n += 1
sev_ok += any(it["finding"]["severity"] == "fatal" for it in good)
else:
got = ", ".join(sorted({it["verdict"] for it in items}))
misses.append(f"{uid}({e['defect']} {e.get('variant', '')})→ 查标结论:{got}")
elif e["expect"] == "ok":
ok_units += 1
if e.get("trap"):
trap_units += 1
if found:
fp.append(f"{uid}({e['cat']},{e['mode']}):{found[0]['finding']['title'][:40]}")
if e.get("trap"):
fp_by_trap[e["trap"]] += 1
elif found:
other_findings += 1
findings = [it for it in res["items"] if it["finding"]]
lines = [
f"# 查标评分:{a.bid} · 运行 {a.run}",
"",
f"- 投标文件:{manifest['kind']},风格 {manifest['style']},{manifest['split']};清单 {len(res['items'])} 条,报出问题 {len(findings)} 个",
f"- 金标准:{'含未复核的问题(试跑口径)' if a.include_unreviewed else '只算复核过的问题'};复核方式见决策 E1",
f"- 模型:{res['summary']['usage']}",
"",
"## 召回",
"",
f"- 查标召回(读标抽到的要求上):{pct(rec['查标'], rec_total['查标'])}",
f"- 端到端召回(含读标漏抽):{pct(rec['端到端'], rec_total['端到端'])}",
f"- 致命级:{pct(rec['致命级'], rec_total['致命级'])};其中级别也判成致命的 {pct(sev_ok, sev_n)}",
"",
"| 分类 | 召回 |",
"|---|---|",
*[f"| {k} | {pct(rec[k], rec_total[k])} |" for k in sorted(rec_total) if k not in ("端到端", "查标", "致命级")],
"",
"## 误报",
"",
f"- 规矩响应的要求 {ok_units} 条,报了问题的 {len(fp)} 条(每 100 条 {100 * len(fp) / ok_units:.1f})"
if ok_units
else "- 没有规矩响应的要求",
f"- 干扰项 {trap_units} 条,误报 {dict(fp_by_trap)}",
f"- 要求本来不需要响应或无法判断(na / unknown)却报了问题:{other_findings}",
f"- 无法对照(对不上任何标注单元)的问题:{len(unmapped)}",
"",
f"## 漏报({len(misses)})",
"",
*[f"- {x}" for x in misses],
"",
f"## 读标漏抽({len(extraction_miss)})",
"",
*[f"- {x}" for x in extraction_miss],
"",
f"## 误报明细({len(fp)})",
"",
*[f"- {x}" for x in fp[:80]],
*(["- ……"] if len(fp) > 80 else []),
"",
f"## 规则问题(第 2 步才查,这里只列出):{len(manifest['rule_defects'])} 个",
"",
]
out = d / "runs" / a.run
(out / "score.md").write_text("\n".join(lines))
summary = {
"recall": {k: [rec[k], rec_total[k]] for k in rec_total},
"fp": len(fp),
"ok_units": ok_units,
"fp_by_trap": dict(fp_by_trap),
"unmapped": len(unmapped),
"other_findings": other_findings,
"extraction_miss": len(extraction_miss),
"include_unreviewed": a.include_unreviewed,
}
(out / "score.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1))
print("\n".join(lines[:20]))
if __name__ == "__main__":
main()