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:
488
server/src/bid_doctor/check/qualification.py
Normal file
488
server/src/bid_doctor/check/qualification.py
Normal 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
|
||||
Reference in New Issue
Block a user