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:
@@ -11,11 +11,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -23,8 +25,9 @@ from bid_doctor.ai import prompts
|
||||
from bid_doctor.ai import service as ai_service
|
||||
from bid_doctor.ai.providers.base import near_limit
|
||||
from bid_doctor.ai.service import OutputIncomplete, provider
|
||||
from bid_doctor.check import applicability, findings, match
|
||||
from bid_doctor.check import applicability, findings, match, rules
|
||||
from bid_doctor.checklist import response_mode
|
||||
from bid_doctor.checklist.extract import amount_yuan
|
||||
from bid_doctor.core.config import get_settings
|
||||
from bid_doctor.core.db import TenantContext
|
||||
from bid_doctor.document.pipeline import text_layer
|
||||
@@ -102,6 +105,9 @@ def checklist(tender: str, run: str) -> dict[UUID, dict[str, Any]]:
|
||||
text = "".join(by_sid[k].text for k in r.get("sentences") or [] if k in by_sid)
|
||||
raw = (r.get("raw") or {}).get("tech_param") or {}
|
||||
spec = {"tech_param": {k: raw[k] for k in ("param", "op", "value", "unit") if raw.get(k)}} if raw else {}
|
||||
sched = (r.get("raw") or {}).get("schedule") or {}
|
||||
if sched:
|
||||
spec["schedule"] = {k: sched[k] for k in ("date_kind", "at") if sched.get(k)}
|
||||
out[UUID(int=i + 1)] = {
|
||||
"title": r.get("title"),
|
||||
"requirement_text": text or None,
|
||||
@@ -114,6 +120,53 @@ def checklist(tender: str, run: str) -> dict[UUID, dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def facts(tender: str, run: str, items: dict[UUID, dict[str, Any]]) -> rules.Facts:
|
||||
"""项目侧的值:读标评测抽出的项目基本信息(和产品里一样来自抽取);所投的包按招标档案(相当于用户选包)。"""
|
||||
base = EVALS / "out" / "runs" / run / tender
|
||||
info = json.loads((base / "info.json").read_text())
|
||||
f = {k: v.get("value") for k, v in info["fields"].items()}
|
||||
profile = json.loads((OUT / "tenders" / f"{tender}.json").read_text())["project"]
|
||||
deadline = next(
|
||||
(
|
||||
dt.date.fromisoformat(s["at"][:10])
|
||||
for r in json.loads((base / "items.json").read_text())
|
||||
if r.get("zone") == "formal"
|
||||
and (s := (r.get("raw") or {}).get("schedule") or {}).get("date_kind") == "bid_deadline"
|
||||
and s.get("at")
|
||||
),
|
||||
None,
|
||||
)
|
||||
lots = info.get("lots") or []
|
||||
ceiling, scope, note = None, "total", None
|
||||
if lots:
|
||||
lot = next(
|
||||
(x for x in lots if str(x["lot_no"]).lstrip("0") == str(profile.get("bidding_lot", "")).lstrip("0")), None
|
||||
)
|
||||
if lot is None and len(lots) == 1:
|
||||
lot = lots[0]
|
||||
if lot is not None:
|
||||
c = amount_yuan((lot.get("ceiling") or {}).get("value"), (lot.get("ceiling") or {}).get("unit"))
|
||||
ceiling, scope = (Decimal(c) if c else None), "lot" if len(lots) > 1 else "total"
|
||||
else:
|
||||
note = "所投采购包不明确,报价没有和限价比对"
|
||||
else:
|
||||
c = amount_yuan(f.get("ceiling_total"), info["fields"].get("ceiling_total", {}).get("unit"))
|
||||
ceiling = Decimal(c) if c else None
|
||||
days, item = rules.required_validity(items)
|
||||
return rules.Facts(
|
||||
name=f.get("name"),
|
||||
code=f.get("code"),
|
||||
purchaser=f.get("purchaser"),
|
||||
agency=f.get("agency"),
|
||||
deadline=deadline,
|
||||
ceiling=ceiling,
|
||||
ceiling_scope=scope,
|
||||
ceiling_note=note,
|
||||
validity_days=days,
|
||||
validity_item=item,
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("bid")
|
||||
@@ -122,6 +175,7 @@ async def main() -> None:
|
||||
ap.add_argument("--baseline", action="store_true", help="只检索、不调模型的基线")
|
||||
ap.add_argument("--v1", action="store_true", help="第 1 步的做法:提示词 v1、不带同一行的单元格(对比基线)")
|
||||
ap.add_argument("--no-modes", action="store_true", help="不按响应方式和「要不要回答」筛选(C12 之前的做法)")
|
||||
ap.add_argument("--rules-only", action="store_true", help="只重跑规则检查,写回已有运行的 results.json")
|
||||
args = ap.parse_args()
|
||||
d = OUT / args.bid
|
||||
manifest = json.loads((d / "manifest.json").read_text())
|
||||
@@ -136,8 +190,39 @@ async def main() -> None:
|
||||
pdf.stem,
|
||||
text_layer.body_sentences(pages),
|
||||
[p.page_no for p in pages if p.text_source == "empty"],
|
||||
[s for p in pages for s in p.sentences if s.location == "header_footer"],
|
||||
)
|
||||
)
|
||||
hits, rules_cov = rules.run(
|
||||
facts(manifest["tender"], args.checklist, items), docs, gold.sentences(manifest["tender"])
|
||||
)
|
||||
|
||||
def rule_rows(covered: set[tuple[str, int, str]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"code": h.code,
|
||||
"type": h.type,
|
||||
"severity": h.severity,
|
||||
"title": h.title,
|
||||
"explanation": h.explanation,
|
||||
"evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in h.evidence],
|
||||
}
|
||||
for h in rules.drop_covered(hits, covered)
|
||||
]
|
||||
|
||||
if args.rules_only:
|
||||
prev = d / "runs" / args.name / "results.json"
|
||||
data = json.loads(prev.read_text())
|
||||
covered = {
|
||||
(e["volume"], e["p"], "".join(e["text"].split()))
|
||||
for it in data["items"]
|
||||
if it["verdict"] == "negative_deviation"
|
||||
for e in it["evidence"]
|
||||
}
|
||||
data["rules"], data["rules_coverage"] = rule_rows(covered), rules_cov
|
||||
prev.write_text(json.dumps(data, ensure_ascii=False, indent=1))
|
||||
print(f"{args.bid}:规则问题 {len(data['rules'])} 个")
|
||||
return
|
||||
ai_service.run_json = baseline_run_json if args.baseline else cached_run_json # type: ignore[assignment]
|
||||
if args.v1:
|
||||
match.PROMPT = ("check.match", 1)
|
||||
@@ -196,7 +281,19 @@ async def main() -> None:
|
||||
out = d / "runs" / args.name
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
summary = {"items": len(rows), "usage": usage, "verify_failures": stats.verify_failures, "splits": stats.splits}
|
||||
(out / "results.json").write_text(json.dumps({"summary": summary, "items": rows}, ensure_ascii=False, indent=1))
|
||||
covered = {
|
||||
(doc.label, s.page_no, "".join(s.text.split()))
|
||||
for res in results.values()
|
||||
if res.verdict == "negative_deviation"
|
||||
for doc, s in res.evidence
|
||||
}
|
||||
(out / "results.json").write_text(
|
||||
json.dumps(
|
||||
{"summary": summary, "items": rows, "rules": rule_rows(covered), "rules_coverage": rules_cov},
|
||||
ensure_ascii=False,
|
||||
indent=1,
|
||||
)
|
||||
)
|
||||
findings_n = sum(r["finding"] is not None for r in rows)
|
||||
print(
|
||||
f"{args.bid}:清单 {len(rows)} 条,问题 {findings_n} 个,模型调用 {usage['calls']} 次(缓存 {usage['cached']}),"
|
||||
|
||||
Reference in New Issue
Block a user