- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、 late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口 - 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配), QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级, 出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查 - 响应错位规则不再把资质表的证书名称格当成照抄的要求 - 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化 - 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
15 KiB
Python
357 lines
15 KiB
Python
"""离线查标评测:拿读标评测的清单(evals/out/runs/<运行>/<招标文件>/items.json)对一份虚构投标文件跑响应匹配。
|
||
|
||
用法(在 server/ 下,模型配置读根目录 .env):
|
||
../.venv/bin/python ../evals/check/run_check.py <投标文件编号> [--checklist fix3] [--name <运行名>]
|
||
输入:evals/out/check/<投标文件编号>/v*.pdf(render.py 渲染的)、manifest.json 里的 tender。
|
||
输出:evals/out/check/<投标文件编号>/runs/<运行名>/results.json(每条清单项的结论、投标侧证据、生成的问题)。
|
||
同一模型 + 同一提示词版本 + 同一输入只调用一次模型(和读标评测共用 evals/out/cache/),只改规则时重放不花钱。
|
||
"""
|
||
|
||
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
|
||
|
||
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, deviation, findings, match, qualification, rules
|
||
from bid_doctor.checklist import response_mode
|
||
from bid_doctor.checklist import price
|
||
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
|
||
from bid_doctor.qualification.service import Record
|
||
|
||
EVALS = pathlib.Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(EVALS))
|
||
import gold # noqa: E402
|
||
|
||
OUT = EVALS / "out" / "check"
|
||
CACHE = EVALS / "out" / "cache"
|
||
usage = {"calls": 0, "cached": 0, "tokens_in": 0, "tokens_out": 0}
|
||
|
||
|
||
async def cached_run_json(
|
||
ctx: Any, *, job_type: str, job_id: Any, task: str, prompt_id: str, version: int, user: str
|
||
) -> dict[str, Any]:
|
||
del ctx, job_type, job_id
|
||
p = prompts.get(prompt_id, version)
|
||
s = get_settings()
|
||
model = s.ai_models.get(p.capability) or s.ai_model
|
||
key = hashlib.sha256(f"{model}\n{prompt_id}\n{version}\n{p.body}\n{user}".encode()).hexdigest()[:32]
|
||
f = CACHE / f"{key}.json"
|
||
if f.exists():
|
||
data = json.loads(f.read_text())
|
||
usage["cached"] += 1
|
||
else:
|
||
r = await provider().complete_json(p.body, user, p.capability, task)
|
||
data = {
|
||
"data": r.data,
|
||
"model": r.model,
|
||
"tokens_in": r.tokens_in,
|
||
"tokens_out": r.tokens_out,
|
||
"incomplete": r.incomplete,
|
||
}
|
||
f.write_text(json.dumps(data, ensure_ascii=False))
|
||
usage["calls"] += 1
|
||
usage["tokens_in"] += data.get("tokens_in") or 0
|
||
usage["tokens_out"] += data.get("tokens_out") or 0
|
||
if data.get("incomplete") or near_limit(data.get("tokens_out")):
|
||
raise OutputIncomplete(task, data["data"])
|
||
return data["data"]
|
||
|
||
|
||
ITEM = re.compile(r"^\[(I\d+)\] ")
|
||
CANDS = re.compile(r"^候选句:(.*)$")
|
||
FRAG = re.compile(r"^\[(d\d+p\d+s\d+)\]([^)]*)(.*)$")
|
||
|
||
|
||
async def baseline_run_json(
|
||
ctx: Any, *, job_type: str, job_id: Any, task: str, prompt_id: str, version: int, user: str
|
||
) -> dict[str, Any]:
|
||
"""只检索基线(GUIDELINES §7):不调模型,检索第一名就当作已响应。"""
|
||
del ctx, job_type, job_id, task, prompt_id, version
|
||
frags = {m.group(1): m.group(2) for line in user.splitlines() if (m := FRAG.match(line.strip()))}
|
||
results, key = [], None
|
||
for line in user.splitlines():
|
||
if m := ITEM.match(line.strip()):
|
||
key = m.group(1)
|
||
elif (m := CANDS.match(line.strip())) and key:
|
||
first = m.group(1).split("、")[0]
|
||
results.append(
|
||
{"item": key, "verdict": "responded", "evidence": [{"sid": first, "quote": frags.get(first, "")}]}
|
||
)
|
||
return {"results": results}
|
||
|
||
|
||
def checklist(tender: str, run: str) -> dict[UUID, dict[str, Any]]:
|
||
"""读标评测输出的正式清单 → 查标的清单项(和检查快照的 payload 同样的字段)。"""
|
||
rows = json.loads((EVALS / "out" / "runs" / run / tender / "items.json").read_text())
|
||
by_sid = {gold.sid(s): s for s in gold.sentences(tender)}
|
||
out: dict[UUID, dict[str, Any]] = {}
|
||
for i, r in enumerate(rows):
|
||
if r.get("zone") != "formal":
|
||
continue
|
||
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,
|
||
"categories": r.get("categories") or [],
|
||
"importance": r.get("importance"),
|
||
"importance_basis": r.get("importance_basis"),
|
||
"spec": spec,
|
||
"sentences": r.get("sentences") or [],
|
||
}
|
||
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)
|
||
lot_no = str(profile.get("bidding_lot") or "").lstrip("0")
|
||
caps = [
|
||
rules.ItemCap(c.name, c.cap, c.per)
|
||
for c in price.caps(gold.sentences(tender))
|
||
if not c.lot_no or not lot_no or c.lot_no.lstrip("0") == lot_no
|
||
]
|
||
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,
|
||
caps=caps,
|
||
)
|
||
|
||
|
||
def library(kind: str) -> list[Record]:
|
||
"""模拟的资质库(qualification-plan §5):empty 空库;valid 每类一条、有效、等级最高;partial 只有 ISO 9001;
|
||
expired 每类一条、2020 年就过期。软著不进模拟(不查有效期)。"""
|
||
best = {"asc": "5级", "cn": "一级", "jia": "甲级"}
|
||
fams = [f for f in qualification.FAMILIES if f.key != "softcopy"]
|
||
if kind == "partial":
|
||
fams = [qualification.BY_KEY["iso9001"]]
|
||
if kind == "empty":
|
||
return []
|
||
to = dt.date(2020, 1, 1) if kind == "expired" else dt.date(2030, 12, 31)
|
||
return [Record(uuid4(), f.label, "certificate", best.get(f.levels or ""), "SIM-001", to) for f in fams]
|
||
|
||
|
||
async def main() -> None:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("bid")
|
||
ap.add_argument("--checklist", default="fix3")
|
||
ap.add_argument("--name", default="base")
|
||
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")
|
||
ap.add_argument("--no-direct", action="store_true", help="不做偏离栏直读(对比用)")
|
||
ap.add_argument("--no-mismatch", action="store_true", help="不做响应错位判断(对比用)")
|
||
ap.add_argument("--modes", help="人工确认后的响应方式,如 technical=implicit,commercial=implicit")
|
||
ap.add_argument("--library", default="empty", choices=["empty", "valid", "partial", "expired"], help="模拟的资质库")
|
||
args = ap.parse_args()
|
||
d = OUT / args.bid
|
||
manifest = json.loads((d / "manifest.json").read_text())
|
||
items = checklist(manifest["tender"], args.checklist)
|
||
docs = []
|
||
for n, pdf in enumerate(sorted(d.glob("v*.pdf")), start=1):
|
||
pages = text_layer.extract(f"{args.bid}-{pdf.stem}", pdf.read_bytes())
|
||
docs.append(
|
||
match.BidDoc(
|
||
uuid4(),
|
||
n,
|
||
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"],
|
||
)
|
||
)
|
||
project_facts = facts(manifest["tender"], args.checklist, items)
|
||
hits, rules_cov = rules.run(project_facts, 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)
|
||
match.ROW_MATES = 0
|
||
|
||
async def progress(_: float) -> None:
|
||
return None
|
||
|
||
ctx = TenantContext(tenant_id=UUID(int=0), user_id=UUID(int=0))
|
||
all_items = items
|
||
decisions: dict[UUID, applicability.Decision] = {}
|
||
if not args.no_modes:
|
||
modes = response_mode.detect(gold.sentences(manifest["tender"]))
|
||
for pair in filter(None, (args.modes or "").split(",")):
|
||
cat, mode = pair.split("=") # 模拟清单页上人工确认 / 改过响应方式(C12 的人工确认点)
|
||
m = modes[cat]
|
||
modes[cat] = response_mode.Mode(cat, mode, "found", m.explicit, m.implicit)
|
||
decisions = {i: applicability.decide(p, modes) for i, p in items.items()}
|
||
items = {i: p for i, p in items.items() if decisions[i].match}
|
||
results, stats = await match.run(ctx, uuid4(), items, docs, progress)
|
||
if not args.no_direct:
|
||
deviation.direct_read(results, docs)
|
||
if not args.no_mismatch:
|
||
deviation.mismatch_read(results, items, docs)
|
||
if decisions:
|
||
match.apply_modes(results, items, decisions)
|
||
match.scanned_fallback(results, items, decisions, docs)
|
||
qual_hits, qual_cov = qualification.run(all_items, results, docs, library(args.library), project_facts.deadline)
|
||
rows = []
|
||
for item_id, item in all_items.items():
|
||
if item_id not in items:
|
||
rows.append(
|
||
{
|
||
"title": item["title"],
|
||
"sentences": item["sentences"],
|
||
"categories": item["categories"],
|
||
"importance": item["importance"],
|
||
"verdict": f"skipped:{decisions[item_id].skip}",
|
||
"layer": None,
|
||
"evidence_status": "none",
|
||
"reason_code": None,
|
||
"evidence": [],
|
||
"detail": {},
|
||
"finding": None,
|
||
}
|
||
)
|
||
continue
|
||
res = results[item_id]
|
||
spec = findings.spec(res, item)
|
||
rows.append(
|
||
{
|
||
"title": item["title"],
|
||
"sentences": item["sentences"],
|
||
"categories": item["categories"],
|
||
"importance": item["importance"],
|
||
"verdict": res.verdict,
|
||
"layer": res.layer,
|
||
"evidence_status": res.evidence_status,
|
||
"reason_code": res.reason_code,
|
||
"evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in res.evidence],
|
||
"detail": res.detail,
|
||
"finding": {"type": spec.type, "severity": spec.severity, "title": spec.title} if spec else 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}
|
||
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,
|
||
"qualification": [
|
||
{
|
||
"code": h.code,
|
||
"severity": h.severity,
|
||
"title": h.title,
|
||
"explanation": h.explanation,
|
||
"source": h.detail["source"],
|
||
"evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in h.evidence],
|
||
}
|
||
for h in qual_hits
|
||
],
|
||
"qualification_coverage": {"library": args.library, **qual_cov.to_api()},
|
||
},
|
||
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']}),"
|
||
f"token {usage['tokens_in']} / {usage['tokens_out']}"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|