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

@@ -0,0 +1,488 @@
"""资质匹配(FR-M3-05,qualification-plan §3):证书在投标截止日有没有过期、等级够不够、有没有覆盖,不调模型(QL1)。
- 证书类别词表:IT 集成常见的企业资质,别名含国标等同写法;等级按类别比较(数字越大越高,或「一级」最高);
- 产品证书(节能认证、CCC、商用密码产品)和人员证书不进资质匹配:厂商、人员的证书不是用户的资质(红线 5);
- 证据两个来源:投标文字层(资质情况表那一行、写着有效期的那一句)按清单项定级;只有资质库记录的降一级(QL2);
- 投标文件和资质库冲突时以投标文件为准(QL4);资质库有记录但缺这一类才报「未覆盖」,空库不报(QL3);
- 一条清单项里每类证书出一个问题(过期和等级不足分开报);它比扫描件兜底的「请人工核对」更具体,有资质问题时代替那一条;
- 说明只写事实,不写整改建议;认不出类别、日期归属不清的不判断,写进覆盖范围。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date
from typing import Any
from uuid import UUID
from bid_doctor.check.findings import severity as item_severity
from bid_doctor.check.match import BidDoc, Result
from bid_doctor.check.rules import Hit, dates_in
from bid_doctor.document.pipeline import layout
from bid_doctor.document.pipeline.text_layer import Sentence, norm
from bid_doctor.qualification.service import Record
LABEL = "资质核对(证书有效期、等级、有没有覆盖)"
@dataclass(frozen=True, slots=True)
class Family:
key: str
label: str
pattern: re.Pattern[str] # 在 norm() 后转大写的文本上匹配
levels: str | None = None # asc:数字越大越高;cn:「一级」最高;jia:甲 > 乙 > 丙
prefix: str | None = None # 等级写法的前缀(CS3、CMMI3)
subtypes: tuple[str, ...] = () # 服务类别(CCRC 的安全集成、风险评估……)
def _f(key: str, label: str, pattern: str, **kw: Any) -> Family:
return Family(key, label, re.compile(pattern), **kw)
FAMILIES = (
_f("iso9001", "ISO 9001 质量管理体系认证", r"ISO(?:/IEC)?/?9001|GB/?T19001|(?<!医疗器械)质量管理体系认证"),
_f("iso14001", "ISO 14001 环境管理体系认证", r"ISO/?14001|GB/?T24001|环境管理体系认证"),
_f(
"iso45001",
"ISO 45001 职业健康安全管理体系认证",
r"ISO/?45001|OHSAS/?18001|GB/?T45001|GB/?T28001|职业健康安全管理体系认证",
),
_f("iso27001", "ISO 27001 信息安全管理体系认证", r"ISO(?:/IEC)?/?27001|GB/?T22080|信息安全管理体系认证"),
_f(
"iso20000",
"ISO 20000 信息技术服务管理体系认证",
r"ISO(?:/IEC)?/?20000|GB/?T24405|(?:信息技术|IT)服务管理体系认证",
),
_f("iso22301", "ISO 22301 业务连续性管理体系认证", r"ISO/?22301|GB/?T30146|业务连续性管理体系认证"),
_f("cmmi", "CMMI 能力成熟度模型集成", r"CMMI|能力成熟度模型集成", levels="asc", prefix="CMMI|ML"),
_f(
"cs",
"信息系统建设和服务能力等级",
r"信息系统建设和服务能力|(?<![A-Z])CS[1-5](?![0-9])",
levels="asc",
prefix="CS",
),
_f(
"itss",
"ITSS 信息技术服务运行维护标准符合性证书",
r"ITSS|信息技术服务标准|运行维护服务能力|运维服务能力",
levels="cn",
),
_f("ccia", "CCIA 信息系统业务安全服务资质", r"CCIA|信息系统业务安全服务资质|网络安全产业联盟", levels="cn"),
_f(
"ccrc",
"CCRC 信息安全服务资质",
r"CCRC|(?<!业务)信息安全服务资质|网络安全审查技术与认证中心",
levels="cn",
subtypes=(
"安全集成",
"风险评估",
"应急处理",
"软件安全开发",
"安全运维",
"灾难备份",
"网络安全审计",
"数据安全",
),
),
_f("eie", "电子与智能化工程专业承包资质", r"电子与智能化工程专业承包", levels="cn"),
_f("intel_design", "建筑智能化系统设计专项资质", r"建筑智能化系统设计专项|建筑智能化.{0,4}工程设计", levels="jia"),
_f("secret", "涉密信息系统集成资质", r"涉密信息系统集成", levels="jia"),
_f("safety", "安全生产许可证", r"安全生产许可证"),
_f("softcopy", "计算机软件著作权登记证书", r"软件著作权|著作权登记|软著"),
)
BY_KEY = {f.key: f for f in FAMILIES}
# 产品证书、人员证书:跟着产品或人走,不是投标人自己的资质,不进资质匹配
EXCLUDE = re.compile(
r"产品|设备|所投|厂商|厂家|制造商|原厂|节能|环境标志|CCC|3C|商用密码|医疗器械|IPV6|检测报告|"
r"人员|工程师|项目经理|负责人|技术员|持证|个人|兼容"
)
CERT_WORDS = re.compile(r"证书|资质|认证")
# 不是证书类资质的要求(营业执照、法人证书、联合体的资质认定规则):不算「没认出类别」
NOT_CERT = re.compile(r"营业执照|法人证书|联合体")
# 照抄要求的写法:投标人在响应表里抄一遍要求,不是读到的证书事实
REQUIREMENT_LIKE = re.compile(r"须|应|要求|及以上|以上|不低于|得\d|\d分|分)|分\)")
VALIDITY_LABEL = re.compile(r"有效期|到期|截止")
LONG_TERM = re.compile(r"长期有效|长期")
CN_NUM = {"特": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "壹": 1, "贰": 2, "叁": 3, "肆": 4, "伍": 5}
JIA = {"甲": 3, "乙": 2, "丙": 1}
HEADER_LOOKBACK = 15 # 表头最多往上找这么多行
CELL_SEP = "|" # 拼一行的格子时用的分隔,免得「CS2」和下一格的「2024-01-01」连成一串
COPY_RATIO = 0.8
LEVEL_CELL_MAX = 8 # 等级那一栏的字数上限(「CS3」「二级」「甲级」) # 句子占要求原文的这么大比例以上,算照抄的要求
def text_of(s: str) -> str:
return norm(s).upper()
def families_in(text: str) -> list[Family]:
t = text_of(text)
return [f for f in FAMILIES if f.pattern.search(t)]
def family_of_record(r: Record) -> Family | None:
if r.kind == "software_copyright":
return BY_KEY["softcopy"]
found = [f for f in families_in(r.name) if f.key != "softcopy"]
return found[0] if found else None
def level_rank(fam: Family, text: str) -> tuple[int, str] | None:
"""文字里写的等级 → (名次, 原文),名次越大越高;认不出返回 None。"""
t = text_of(text)
if fam.levels == "asc":
m = re.search(rf"(?:{fam.prefix})\s*-?\s*([1-5])(?![0-9])", t) or re.search(r"([一二三四五壹贰叁肆伍1-5])级", t)
if m:
g = m.group(1)
return (int(g) if g.isdigit() else CN_NUM[g]), m.group(0)
elif fam.levels == "cn":
if m := re.search(r"([特一二三四壹贰叁肆1-4])级", t):
g = m.group(1)
return 10 - (int(g) if g.isdigit() else CN_NUM[g]), m.group(0)
elif fam.levels == "jia":
if m := re.search(r"([甲乙丙])级", t):
return JIA[m.group(1)], m.group(0)
return None
def segment(fam: Family, text: str) -> str:
"""要求里属于这一类证书的那一段:从它的名称起,到下一类证书的名称为止(「ITSS 三级得 1 分;CS3 及以上得 2 分」
里,ITSS 的等级不能读成 CS 的)。没有别的证书时就是全文。"""
t = text_of(text)
m = fam.pattern.search(t)
if not m:
return t
others = [o.start() for f in FAMILIES if f.key != fam.key for o in f.pattern.finditer(t) if o.start() > m.start()]
return t[m.start() : min(others, default=len(t))]
def subtype_of(fam: Family, text: str) -> str | None:
t = text_of(text)
return next((x for x in fam.subtypes if x in t), None)
@dataclass(slots=True)
class BidFact:
"""投标文字层上读到的一张证书:在哪一句(和同一行的格子),有效期、等级。"""
evidence: list[tuple[BidDoc, Sentence]]
valid_to: date | None = None
long_term: bool = False
level: tuple[int, str] | None = None
@dataclass(slots=True)
class DocIndex:
"""一份投标文件的版面索引,每次检查建一次:句子 → 所在行、每页的行、规范化后的文字。"""
doc: BidDoc
rows: dict[int, list[Sentence]]
page_rows: dict[int, list[list[Sentence]]]
texts: list[tuple[Sentence, str]]
@classmethod
def build(cls, doc: BidDoc) -> DocIndex:
page_rows = {no: layout.rows(page) for no, page in layout.pages(doc.sentences).items()}
rows = {id(c): row for page in page_rows.values() for row in page for c in row}
return cls(doc, rows, page_rows, [(s, text_of(s.text)) for s in doc.sentences])
def row_level(fam: Family, row: list[Sentence], row_text: str) -> tuple[int, str] | None:
"""一行里的等级:先看短的格子(等级那一栏),证书编号里常带等级字样(「CS3-3201-…」),整行读会读错。"""
short = [lv for x in row if len(norm(x.text)) <= LEVEL_CELL_MAX and (lv := level_rank(fam, x.text))]
return short[0] if short else level_rank(fam, row_text)
def bid_facts(fam: Family, docs: list[DocIndex], requirement: str) -> list[BidFact]:
"""投标文字层上写着这一类证书的句子:取同一行(表格)或同一句里的有效期、等级。
只认这一行 / 这一句里只有这一类证书的;照抄要求的句子不算。"""
req = text_of(requirement)
out: list[BidFact] = []
for ix in docs:
seen: set[int] = set()
for s, t in ix.texts:
if not fam.pattern.search(t) or REQUIREMENT_LIKE.search(t):
continue
if req and (req in t or (t in req and len(t) >= COPY_RATIO * len(req))):
continue # 整句就是要求原文(证书名称只是要求的一部分时不算)
row = ix.rows.get(id(s)) or [s]
if id(row[0]) in seen:
continue
seen.add(id(row[0]))
row_text = CELL_SEP.join(x.text for x in row)
if {f.key for f in families_in(row_text)} - {fam.key, "softcopy"}:
continue # 一行里有两类证书,日期归属不清
evidence = [(ix.doc, x) for x in row if x is s or dates_in(x.text) or level_rank(fam, x.text)]
fact = BidFact(evidence=evidence, level=row_level(fam, row, row_text))
labeled = VALIDITY_LABEL.search(row_text) or _header_has_validity(ix.page_rows.get(s.page_no, []), row)
ds = dates_in(row_text)
if LONG_TERM.search(row_text) and labeled:
fact.long_term = True
elif ds and labeled:
fact.valid_to = max(ds) # 起止日期都在这一行时,晚的那个是「有效期至」
out.append(fact)
return out
def _header_has_validity(rows: list[list[Sentence]], row: list[Sentence]) -> bool:
"""表头(同一页往上几行)里有没有「有效期」这一栏。"""
try:
i = next(k for k, r in enumerate(rows) if r and row and r[0] is row[0])
except StopIteration:
return False
return any(VALIDITY_LABEL.search(CELL_SEP.join(x.text for x in r)) for r in rows[max(0, i - HEADER_LOOKBACK) : i])
@dataclass(slots=True)
class Coverage:
library: int = 0
checked: int = 0
by_library: int = 0
unrecognized: int = 0
excluded: int = 0
no_deadline: bool = False
def to_api(self) -> dict[str, Any]:
lines: list[str] = []
if self.library == 0:
lines.append("资质库还没有录入资质,有效期和等级没有按资质库核对(只看投标文件文字部分)")
if self.no_deadline:
lines.append("项目信息里没有投标截止时间,证书有效期没有比对")
if self.unrecognized:
lines.append(f"{self.unrecognized} 条资质要求没认出是哪类证书,有效期和等级没有核对")
lines.append("证书主体和投标人是否一致:还没有上线")
checked = f"{LABEL}:{self.checked} 条资质要求"
if self.by_library:
checked += f",其中 {self.by_library} 条按资质库记录判断"
if self.excluded:
checked += f";产品证书、人员证书 {self.excluded} 条不在资质匹配范围内"
return {
"checked": checked,
"not_checked": lines,
"detail": {
"library": self.library,
"checked": self.checked,
"by_library": self.by_library,
"unrecognized": self.unrecognized,
"excluded": self.excluded,
},
}
@dataclass(slots=True)
class Issue:
kind: str # expired / level_low / not_covered
source: str # bid_text / library
family: Family
text: str
severity: str
evidence: list[tuple[BidDoc, Sentence]] = field(default_factory=list[tuple[BidDoc, Sentence]])
detail: dict[str, Any] = field(default_factory=dict[str, Any])
# 投标文件里有(已响应)、可能在扫描件里、或评分项没逐条找响应(None):资质库的过期、等级照常提示;
# 文字版投标文件确实没找到的,保留「未找到响应」,资质库的情况只补进说明
SEEN = ("responded", "scanned", None)
CODES = {"expired": "QUAL.EXPIRED", "level_low": "QUAL.LEVEL_LOW", "not_covered": "QUAL.NOT_COVERED"}
TITLES = {"expired": "证书在投标截止日已过期", "level_low": "证书等级低于要求", "not_covered": "资质库里没有这类资质"}
ORDER = {"expired": 0, "level_low": 1, "not_covered": 2}
def _d(x: date) -> str:
return f"{x.year}年{x.month}月{x.day}日"
def _lower(sev: str) -> str:
return "important" if sev == "fatal" else sev
def _best_valid(records: list[Record]) -> Record | None:
"""同一类有几条(旧证书和换发后的新证书)时取有效期最晚的;长期有效最好。"""
return max(records, key=lambda r: date.max if r.valid_to is None else r.valid_to) if records else None
def _rec(r: Record) -> str:
no = f",编号 {r.cert_no}" if r.cert_no else ""
to = f"有效期至 {_d(r.valid_to)}" if r.valid_to else "长期有效"
return f"《{r.name}》({to}{no})"
def check_item(
item: dict[str, Any],
result: Result | None,
fams: list[Family],
docs: list[DocIndex],
library: list[Record],
by_family: dict[str, list[Record]],
deadline: date | None,
cov: Coverage,
) -> tuple[list[Issue], list[str]]:
"""一条清单项:返回资质问题和给原有问题补的说明(资质库的情况)。"""
requirement = " ".join(str(item.get(k) or "") for k in ("title", "requirement_text", "evidence_required"))
full = item_severity(item)
verdict = result.verdict if result else None
issues: list[Issue] = []
notes: list[str] = []
used_library = False
for fam in fams:
facts = bid_facts(fam, docs, requirement)
recs = by_family.get(fam.key, [])
if fam.key == "softcopy": # 软著长期有效、没有等级:只给扫描件兜底补一句
if recs and verdict in ("scanned", "not_found"):
notes.append(f"资质库里有 {len(recs)} 件软件著作权")
continue
best = _best_valid(recs)
# 有效期:投标文字层读到日期就以它为准(QL4),读不到再看资质库
dated = [x for x in facts if x.valid_to or x.long_term]
if deadline and dated:
if not any(x.long_term or (x.valid_to and x.valid_to >= deadline) for x in dated):
latest = max(x.valid_to for x in dated if x.valid_to)
text = f"投标文件里写的有效期至 {_d(latest)},早于投标截止日 {_d(deadline)}。"
if best and (best.valid_to is None or best.valid_to >= deadline):
text += f"资质库里有{_rec(best)},投标文件附的可能是旧证书。"
issues.append(
Issue(
"expired",
"bid_text",
fam,
text,
full,
[e for x in dated for e in x.evidence],
{"bid_valid_to": latest.isoformat(), **({"library": best.snapshot()} if best else {})},
)
)
elif deadline and best and best.valid_to and best.valid_to < deadline and verdict in SEEN:
used_library = True
text = f"按资质库记录,{_rec(best)}早于投标截止日 {_d(deadline)}。投标文件文字部分没有写有效期。"
issues.append(Issue("expired", "library", fam, text, _lower(full), [], {"library": best.snapshot()}))
elif deadline and best and best.valid_to and best.valid_to < deadline and verdict == "not_found":
notes.append(f"资质库里的{_rec(best)}也早于投标截止日")
# 等级:能比较时才比
need = level_rank(fam, segment(fam, requirement)) if fam.levels else None
if need:
got = [x.level for x in facts if x.level]
if got:
top = max(got)
if top[0] < need[0]:
text = f"要求「{need[1]}」,投标文件里写的是「{top[1]}」。"
issues.append(
Issue(
"level_low",
"bid_text",
fam,
text,
full,
[e for x in facts if x.level for e in x.evidence],
{"required_level": need[1], "bid_level": top[1]},
)
)
elif recs and verdict in SEEN:
ranked = [(level_rank(fam, r.level or ""), r) for r in recs]
comparable = [(lv, r) for lv, r in ranked if lv]
if comparable and len(comparable) == len(ranked) and max(lv[0] for lv, _ in comparable) < need[0]:
used_library = True
r = max(comparable, key=lambda x: x[0][0])[1]
text = f"要求「{need[1]}」,按资质库记录,{_rec(r)}的等级是「{r.level}」。"
issues.append(
Issue(
"level_low",
"library",
fam,
text,
_lower(full),
[],
{"required_level": need[1], "library": r.snapshot()},
)
)
# 服务类别(CCRC):要求写了类别,资质库的记录名称里都没写
sub = subtype_of(fam, segment(fam, requirement))
if sub and recs and not any(sub in text_of(r.name) for r in recs) and not facts:
used_library = True
text = f"要求的是「{sub}」类,资质库里的{fam.label}记录没写服务类别,请核对是不是「{sub}」。"
issues.append(Issue("not_covered", "library", fam, text, "hint", [], {"subtype": sub}))
# 未覆盖:投标文字层没找到、资质库有记录但没有这一类(QL3)
if library and not recs and not facts and verdict in ("scanned", "not_found"):
if verdict == "scanned":
used_library = True
text = (
f"投标文件文字部分没有找到{fam.label},资质库里也没有这类资质(资质库有 {len(library)} 条记录)。"
)
issues.append(Issue("not_covered", "library", fam, text, "important", [], {}))
else:
notes.append(f"资质库里也没有{fam.label}")
elif best and verdict == "scanned" and not issues:
notes.append(f"资质库里有{_rec(best)},请核对扫描件里附的是这一张")
if used_library:
cov.by_library += 1
return issues, notes
def run(
items: dict[UUID, dict[str, Any]],
results: dict[UUID, Result],
docs: list[BidDoc],
library: list[Record],
deadline: date | None,
) -> tuple[list[Hit], Coverage]:
"""资质匹配:对资格条件、评分项里认出证书类别的清单项逐条核对。有资质问题的条目,扫描件兜底那条不再单独出;
资质库的情况补进原有问题的说明(results[...].detail["qualification_note"])。"""
cov = Coverage(library=len(library), no_deadline=deadline is None)
index = [DocIndex.build(d) for d in docs]
by_family: dict[str, list[Record]] = {}
for r in library:
if fam := family_of_record(r):
by_family.setdefault(fam.key, []).append(r)
hits: list[Hit] = []
for item_id, item in items.items():
cats = set(item.get("categories") or [])
if not cats & {"qualification", "scoring"}:
continue
requirement = " ".join(str(item.get(k) or "") for k in ("title", "requirement_text", "evidence_required"))
t = text_of(requirement)
fams = families_in(requirement)
if EXCLUDE.search(t):
if fams or CERT_WORDS.search(t):
cov.excluded += 1
continue
if not fams:
if CERT_WORDS.search(t) and not NOT_CERT.search(t):
cov.unrecognized += 1
continue
cov.checked += 1
result = results.get(item_id)
issues, notes = check_item(item, result, fams, index, library, by_family, deadline, cov)
if result is not None and notes:
result.detail = {**result.detail, "qualification_note": ";".join(notes) + "。"}
if not issues:
continue
if result is not None and result.verdict == "scanned":
result.detail = {**result.detail, "superseded_by": "QUAL"}
for fam in dict.fromkeys(x.family.key for x in issues): # 一类证书一个问题:过期和等级不足是两件事
mine = sorted(
(x for x in issues if x.family.key == fam), key=lambda x: (ORDER[x.kind], x.source != "bid_text")
)
top = mine[0]
hits.append(
Hit(
code=CODES[top.kind],
type="qualification",
severity=min((x.severity for x in mine), key=["fatal", "important", "hint"].index),
title=f"{TITLES[top.kind]}:{top.family.label}",
explanation=" ".join(x.text for x in mine),
evidence=[e for x in mine for e in x.evidence],
target=f"{item_id}:{fam}",
item=item_id,
detail={
"kind": top.kind,
"source": top.source,
"family": fam,
"deadline": deadline.isoformat() if deadline else None,
"importance_basis": item.get("importance_basis"),
"issues": [{"kind": x.kind, "source": x.source, **x.detail} for x in mine],
},
tender_refs=[UUID(c) for c in item.get("citation_ids") or []],
)
)
return hits, cov