"""查标评分:对照响应清单给一次离线检查打分(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()