Files
bid-doctor/evals/check/score.py
YANG JIANKUAN db1b1f6f9f feat(check): 规则检查(报价形式、形式要件、响应表),表格按单元格分行
- check/rules.py(规则集 2026.11-r1,不调模型):大小写金额、分项合计、超最高限价、
  多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;
  每条挂投标原文,有依据的挂招标侧出处;缺限价、截止时间、有效期要求时写进覆盖范围
- 文字层的句子带上单元格框,表格按单元格上沿分行
- 按 Fable 审读意见收紧致命级:认不准的降为重要或提示
- 评测:score.py 评规则问题;P07 注入记录改为空白处前后原文,剔除注入到人员表的 D10
- 上传页的检查范围文案同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:44:22 +08:00

310 lines
13 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"}
RULE_CODES = {
"P01": "PRICE.OVER_CEILING_TOTAL",
"P02": "PRICE.OVER_CEILING_LOT",
"P03": "PRICE.OVER_CEILING_ITEM",
"P04": "PRICE.CAPITAL_MISMATCH",
"P05": "PRICE.SUM_MISMATCH",
"P06": "FORM.FOREIGN_PROJECT",
"P07": "FORM.BLANK_FIELD",
"P08": "FORM.DATE_AFTER_DEADLINE",
"P09": "FORM.VALIDITY_SHORT",
"P10": "FORM.CONDITION_ADDED",
"P11": "FORM.MISSING_DOC",
"P12": "PRICE.MULTIPLE_QUOTES",
"P13": "PRICE.ITEM_MISSING",
}
def squash(t: str) -> str:
return "".join(t.split())
def score_rules(manifest: dict[str, Any], res: dict[str, Any]) -> dict[str, Any]:
"""规则问题(P 类):规则编号对上、投标侧出处里有注入的那段原文才算查出;没对上任何注入问题的规则问题算误报。
D10(符合性条件响应表)按表级问题记在 rule_defects 里,它由响应匹配查(negative_deviation),这里一起对照。"""
hits = list(res.get("rules") or [])
used: set[int] = set()
found: Counter[str] = Counter()
total: Counter[str] = Counter()
missed: list[str] = []
for rd in manifest.get("rule_defects") or []:
kind, snip = rd["type"], squash(rd.get("t") or "")
total[kind] += 1
if kind == "D10":
pool = [
(-1, {"evidence": it["evidence"]})
for it in res["items"]
if it["finding"] and it["finding"]["type"] == "negative_deviation"
] + [(i, h) for i, h in enumerate(hits) if h["code"] == "FORM.RESPONSE_NEGATIVE"]
else:
pool = [(i, h) for i, h in enumerate(hits) if h["code"] == RULE_CODES.get(kind)]
key = snip[:12]
cell = squash(rd.get("variant") or "") if kind == "D10" else ""
ok = next(
(
i
for i, h in pool
if any(
(key and (key in squash(e["text"]) or squash(e["text"]) in snip))
or (cell and squash(e["text"]) == cell)
for e in h["evidence"]
)
),
None,
)
if ok is None:
missed.append(f"{rd['id']}({rd.get('variant', '')}):{rd.get('t', '')[:40]}")
continue
found[kind] += 1
if ok >= 0:
used.add(ok)
# 响应表规则报的「负偏离」对上了响应问题(D05 / D06)的注入位置:算那个单元查出(rule_units),不算规则误报
rule_units: set[str] = set()
for i, h in enumerate(hits):
if h["code"] != "FORM.RESPONSE_NEGATIVE":
continue
texts = [squash(e["text"]) for e in h["evidence"]]
for uid, u in manifest["units"].items():
if u.get("expect") != "defect" or "negative_deviation" not in FAMILY.get(u.get("defect", ""), set()):
continue
if any(
(w := squash(x.get("t") or "")[:12]) and any(w in t or (t and t in w) for t in texts)
for x in u.get("where") or []
):
rule_units.add(uid)
used.add(i)
fp = [h for i, h in enumerate(hits) if i not in used]
return {"found": found, "total": total, "missed": missed, "fp": fp, "n": len(hits), "units": rule_units}
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}
rs = score_rules(manifest, res)
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 not good and any(x in rs["units"] for x in manifest["units"] if group.get(x, x) == g):
good = [{"finding": {"type": "negative_deviation", "severity": "important"}}]
rec["其中由响应表规则查出"] += 1
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 []),
"",
]
lines += [
f"## 规则问题:注入 {sum(rs['total'].values())} 个,查出 {sum(rs['found'].values())} 个;规则报出 {rs['n']} 个,误报 {len(rs['fp'])} 个",
"",
"| 问题 | 查出 |",
"|---|---|",
*[f"| {k} | {pct(rs['found'][k], rs['total'][k])} |" for k in sorted(rs["total"])],
"",
*[f"- 漏:{x}" for x in rs["missed"]],
*[
f"- 误报:{h['code']} {h['title']}:{h['evidence'][0]['text'][:50] if h['evidence'] else ''}"
for h in rs["fp"]
],
"",
"### 规则检查没覆盖的",
"",
*[f"- {x}" for x in (res.get("rules_coverage") or {}).get("not_checked", [])],
"",
]
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,
"rules": {
"found": dict(rs["found"]),
"total": dict(rs["total"]),
"fp": len(rs["fp"]),
"fp_by_code": dict(Counter(h["code"] for h in rs["fp"])),
"n": rs["n"],
},
}
(out / "score.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1))
print("\n".join(lines[:20]))
if __name__ == "__main__":
main()