feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 资质库(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:
YANG JIANKUAN
2026-11-04 23:22:50 +08:00
parent e7fb797925
commit 08e3fe7eff
43 changed files with 2643 additions and 49 deletions

View File

@@ -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 当变体。)

View 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()

View File

@@ -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,
)

View File

@@ -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):