feat(check): 规则检查(报价形式、形式要件、响应表),表格按单元格分行

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 16:44:22 +08:00
parent 47c6a1258e
commit db1b1f6f9f
17 changed files with 1792 additions and 20 deletions

View File

@@ -44,6 +44,83 @@ FAMILY = {
"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:
@@ -79,6 +156,7 @@ def main() -> None:
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"]:
@@ -120,6 +198,9 @@ def main() -> None:
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
@@ -182,7 +263,23 @@ def main() -> None:
*[f"- {x}" for x in fp[:80]],
*(["- ……"] if len(fp) > 80 else []),
"",
f"## 规则问题(第 2 步才查,这里只列出):{len(manifest['rule_defects'])} 个",
]
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
@@ -196,6 +293,13 @@ def main() -> None:
"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]))