feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
- 资质库(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>
This commit is contained in:
@@ -61,11 +61,12 @@
|
||||
| D09 | 响应对象错位 | `unresponded` | 按要求项 | 写的是另一台设备的同名参数;另一个采购包;整章是另一个型号 | 第 3 步 |
|
||||
| D10 | 符合性条件响应表填「未完全响应」或空着 | `negative_deviation` | 致命 | 空着;「未完全响应」;「部分响应」 | 第 3 步 |
|
||||
| D11 | 资格证明材料缺失 | `unresponded` | 致命 | 目录列了、正文没有;整份缺失;给的是别的证书 | 第 3、4 步 |
|
||||
| D12 | 资格证书过期(按投标截止日) | `qualification`(过期) | 致命 | 早于截止日;整本证书过期 | 第 4 步 |
|
||||
| D12 | 资格证书过期(按投标截止日) | `qualification`(过期) | 资格致命、评分重要 | 早于截止日;整本证书过期 | 第 4 步(`qual_defects.py` 派生,规则问题口径评分) |
|
||||
| D13 | 评分项要的证明材料缺失 | `unresponded` | 重要 | 业绩合同缺;人员证书缺;社保缺 | 第 3 步 |
|
||||
| D14 | 证书主体和投标人不一致 | `qualification` | 致命 | 子公司;分公司;曾用名 | 第 4 步 |
|
||||
| D15 | 业绩 / 人员证明的时间窗口不符 | `qualification` / `unresponded` | 资格致命、评分重要 | 业绩合同在近三年之外;社保月份不在要求区间 | 第 4 步 |
|
||||
| D14 | 证书主体和投标人不一致 | `qualification` | 致命 | 子公司;分公司;曾用名 | OCR 之后(决策 QL7) |
|
||||
| D15 | 业绩 / 人员证明的时间窗口不符 | `qualification` / `unresponded` | 资格致命、评分重要 | 业绩合同在近三年之外;社保月份不在要求区间 | V1(业绩、人员库,决策 QL7) |
|
||||
| D16 | 授权代表的社保证明缺失(文件要求时) | `unresponded` | 致命 | 没附;附的是别人的;月份不对 | 第 3 步 |
|
||||
| D18 | 资质证书等级低于要求 | `qualification`(等级不足) | 按要求项 | 低一级;证书编号里的等级和等级栏不一致 | 第 4 步 |
|
||||
| D17 | 多包:采购包号空着或写错 | `formality` | 重要 | 偏离表包号空;授权书包号写错;分项表包号写错 | 第 2 步 |
|
||||
|
||||
(v0.1 的 D04「换算后不满足」并入 D03 当变体。)
|
||||
|
||||
97
evals/check/qual_defects.py
Normal file
97
evals/check/qual_defects.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""从规矩的投标文件派生带「企业资质情况表」的投标文件(qualification-plan §5):q00 证书都有效、等级够;
|
||||
q01 注入 D12(有效期早于投标截止日)和 D18(等级低于要求)。
|
||||
|
||||
cd server && ../.venv/bin/python ../evals/check/qual_defects.py <规矩的投标文件> <输出> <q00|q01>
|
||||
然后 ../.venv/bin/python ../evals/check/render.py <输出>
|
||||
|
||||
- 资质情况表是文字表格(证书原件在后面的扫描件页上,和规矩的那份一样),插在资格证明材料那一章的开头;
|
||||
- 证书名称、编号、日期都是虚构的,写法故意和词表的别名不完全一样(国标号在前、括注英文缩写、日期几种写法);
|
||||
- 响应清单照抄规矩的那份(单元都是 expect=ok),rule_defects 记注入的问题;不挂标注单元,不需要复核(E1 只管单元);
|
||||
- 局限:表是对着 check/qualification.py 的词表写的,分数说明不了泛化(qualification-plan §5)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
from typing import Any
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent
|
||||
OUT = ROOT.parent / "out" / "check"
|
||||
HEADER = ["序号", "证书名称", "证书编号", "等级", "发证机构", "有效期"]
|
||||
|
||||
# 招标文件 → 资质情况表的行(名称、编号、等级、发证机构、有效期),全部虚构
|
||||
CERTS: dict[str, list[list[str]]] = {
|
||||
"S01": [
|
||||
["建筑业企业资质证书(电子与智能化工程专业承包)", "D232000001", "贰级", "某省住房和城乡建设厅(虚构)", "2024年5月21日至2029年5月20日"],
|
||||
["安全生产许可证", "(苏)JZ安许证字〔2023〕000016", "—", "某省住房和城乡建设厅(虚构)", "2023年11月16日至2027年11月15日"],
|
||||
["信息系统业务安全服务资质证书(CCIA)", "CCIA-2025-000231", "一级", "某网络安全行业组织(虚构)", "2025-02-01至2028-01-31"],
|
||||
["信息安全服务资质认证证书(安全集成)", "CCRC-2024-ISV-000188", "一级", "某认证中心(虚构)", "有效期至2027.06.30"],
|
||||
],
|
||||
"S04": [
|
||||
["GB/T 19001-2016/ISO 9001:2015 质量管理体系认证证书", "00125Q30001R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
|
||||
["环境管理体系认证证书(ISO 14001)", "00125E30002R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
|
||||
["职业健康安全管理体系认证证书 GB/T 45001-2020", "00125S30003R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
|
||||
],
|
||||
"S07": [
|
||||
["信息技术服务标准(ITSS)运行维护符合性证书", "ITSS-YW-2-320020250001", "二级", "某行业协会(虚构)", "2025年6月1日至2028年5月31日"],
|
||||
["信息系统建设和服务能力等级证书", "CS3-3201-2024000077", "CS3", "某行业联合会(虚构)", "2024-12-31至2028-12-30"],
|
||||
],
|
||||
}
|
||||
# 招标文件 → 注入的问题:(行号, 列号, 新值, 问题编号, 写法)
|
||||
DEFECTS: dict[str, list[tuple[int, int, str, str, str]]] = {
|
||||
"S01": [
|
||||
(1, 5, "2023年11月16日至2026年11月15日", "D12", "资格条件的证书早于截止日到期"),
|
||||
(2, 3, "二级", "D18", "评分项要求一级,写的是二级"),
|
||||
],
|
||||
"S04": [(1, 5, "2023年3月10日至2026年3月9日", "D12", "评分项的证书已过期")],
|
||||
"S07": [
|
||||
(0, 5, "2023年6月1日至2026年5月31日", "D12", "评分项的证书已过期"),
|
||||
(1, 3, "CS2", "D18", "评分项要求 CS3 及以上,写的是 CS2"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def table(rows: list[list[str]], vol: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"type": "para", "text": "企业资质情况表(证书复印件附后)", "vol": vol},
|
||||
{
|
||||
"type": "table",
|
||||
"header": HEADER,
|
||||
"rows": [{"cells": [str(i + 1), *r]} for i, r in enumerate(rows)],
|
||||
"vol": vol,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("clean")
|
||||
ap.add_argument("out")
|
||||
ap.add_argument("kind", choices=["q00", "q01"])
|
||||
a = ap.parse_args()
|
||||
src = OUT / a.clean
|
||||
spec = json.loads((src / "spec.json").read_text())
|
||||
manifest = json.loads((src / "manifest.json").read_text())
|
||||
tender = spec["tender"]
|
||||
rows = [list(r) for r in CERTS[tender]]
|
||||
defects = []
|
||||
if a.kind == "q01":
|
||||
for n, (row, col, value, code, variant) in enumerate(DEFECTS[tender], start=1):
|
||||
rows[row][col - 1] = value
|
||||
defects.append({"id": f"{code}-{n}", "type": code, "variant": variant, "t": value})
|
||||
vol = spec["volumes"][0]
|
||||
at = next(i for i, b in enumerate(vol["blocks"]) if b.get("type") == "heading" and b.get("form") == "evidence")
|
||||
vol["blocks"][at + 1 : at + 1] = table(rows, vol["blocks"][at].get("vol") or "v1")
|
||||
out = OUT / a.out
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
spec["bid"] = a.out
|
||||
manifest.update(bid=a.out, kind="clean" if a.kind == "q00" else "sloppy", density="qualification", rule_defects=defects)
|
||||
(out / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
|
||||
(out / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
|
||||
print(f"{a.out}:{a.kind},资质情况表 {len(rows)} 行,注入 {[d['id'] for d in defects]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -25,13 +25,14 @@ 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, rules
|
||||
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))
|
||||
@@ -175,6 +176,19 @@ def facts(tender: str, run: str, items: dict[UUID, dict[str, Any]]) -> rules.Fac
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
@@ -187,6 +201,7 @@ async def main() -> None:
|
||||
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())
|
||||
@@ -204,9 +219,8 @@ async def main() -> None:
|
||||
[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"])
|
||||
)
|
||||
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 [
|
||||
@@ -261,6 +275,7 @@ async def main() -> None:
|
||||
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:
|
||||
@@ -308,7 +323,24 @@ async def main() -> None:
|
||||
}
|
||||
(out / "results.json").write_text(
|
||||
json.dumps(
|
||||
{"summary": summary, "items": rows, "rules": rule_rows(covered), "rules_coverage": rules_cov},
|
||||
{
|
||||
"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,
|
||||
)
|
||||
|
||||
@@ -58,6 +58,8 @@ RULE_CODES = {
|
||||
"P11": "FORM.MISSING_DOC",
|
||||
"P12": "PRICE.MULTIPLE_QUOTES",
|
||||
"P13": "PRICE.ITEM_MISSING",
|
||||
"D12": "QUAL.EXPIRED", # 资质匹配(qualification-plan §5):按资质情况表那一行的出处对照
|
||||
"D18": "QUAL.LEVEL_LOW",
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +70,7 @@ def squash(t: str) -> str:
|
||||
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 [])
|
||||
hits = list(res.get("rules") or []) + list(res.get("qualification") or [])
|
||||
used: set[int] = set()
|
||||
found: Counter[str] = Counter()
|
||||
total: Counter[str] = Counter()
|
||||
@@ -104,6 +106,9 @@ def score_rules(manifest: dict[str, Any], res: dict[str, Any]) -> dict[str, Any]
|
||||
found[kind] += 1
|
||||
if ok >= 0:
|
||||
used.add(ok)
|
||||
# 清单里同一要求抽成几条时,规则(资质匹配)对每条各报一个、出处相同:算同一个,不算误报(同响应问题的按单元去重)
|
||||
same = (hits[ok]["code"], tuple(e["text"] for e in hits[ok]["evidence"]))
|
||||
used |= {i for i, h in enumerate(hits) if (h["code"], tuple(e["text"] for e in h["evidence"])) == same}
|
||||
# 响应表规则报的「负偏离」对上了响应问题(D05 / D06)的注入位置:算那个单元查出(rule_units),不算规则误报
|
||||
rule_units: set[str] = set()
|
||||
for i, h in enumerate(hits):
|
||||
|
||||
Reference in New Issue
Block a user