Files
bid-doctor/server/tests/test_check_qualification.py
YANG JIANKUAN 08e3fe7eff
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
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>
2026-11-04 23:22:50 +08:00

288 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""资质匹配(check/qualification.py,qualification-plan §3):纯函数,投标文件和资质库都是虚构的。
要求的写法按招标文件的通用说法改写,不照抄样本原文;每条规则至少配一个反例(不该报的写法)。
"""
from __future__ import annotations
from datetime import date
from typing import Any
from uuid import UUID, uuid4
from bid_doctor.check import findings, match, qualification
from bid_doctor.check.qualification import BY_KEY, EXCLUDE, families_in, level_rank, text_of
from bid_doctor.qualification.service import Record
from tests.test_check_rules import cell, doc, para
DEADLINE = date(2026, 11, 20)
def keys(text: str) -> list[str]:
return [f.key for f in families_in(text)]
def test_families_levels_and_exclusions() -> None:
assert keys("投标人须具有有效的电子与智能化工程专业承包二级及以上资质") == ["eie"]
assert keys("具有 GB/T 19001 质量管理体系认证证书得 2 分") == ["iso9001"]
assert keys("提供 ISO/IEC 27001:2022 信息安全管理体系认证证书") == ["iso27001"]
assert keys("具备信息技术服务管理体系认证(ISO20000)") == ["iso20000"]
assert keys("具有 CS3 级及以上信息系统建设和服务能力等级证书") == ["cs"]
assert keys("信息安全服务资质(安全集成类)一级") == ["ccrc"]
assert keys("信息系统业务安全服务资质(CCIA)二级") == ["ccia"] # 不能当成 CCRC
assert keys("医疗器械质量管理体系认证证书") == [] # 产品体系,不是 ISO 9001
assert keys("具有类似项目业绩") == []
cs, cmmi, eie, ccrc, secret = (BY_KEY[k] for k in ("cs", "cmmi", "eie", "ccrc", "secret"))
assert level_rank(cs, "CS3级及以上") == (3, "CS3")
assert level_rank(cmmi, "CMMI 三级或以上")[0] == 3 # type: ignore[index]
assert level_rank(eie, "二级及以上")[0] < level_rank(eie, "一级")[0] # type: ignore[index]
assert level_rank(ccrc, "一级(安全集成)")[0] > level_rank(ccrc, "三级")[0] # type: ignore[index]
assert level_rank(secret, "乙级")[0] < level_rank(secret, "甲级")[0] # type: ignore[index]
assert level_rank(cs, "等级证书") is None
for t in ("所投产品须具有节能产品认证证书", "项目经理须具有信息系统项目管理师证书", "提供原厂 CCC 认证证书"):
assert EXCLUDE.search(text_of(t)), t
def item(title: str, cats: list[str], importance: str = "normal") -> dict[str, Any]:
return {"title": title, "requirement_text": title, "categories": cats, "importance": importance, "citation_ids": []}
def result(verdict: str) -> match.Result:
return match.Result(verdict=verdict, layer="model")
def rec(name: str, valid_to: date | None, level: str | None = None, kind: str = "system_cert") -> Record:
return Record(uuid4(), name, kind, level, "NO-001", valid_to)
def cert_table(name: str, level: str, valid: str) -> list[qualification.Sentence]:
"""虚构的「企业资质情况表」:表头一行 + 一行证书。"""
return [
cell(1, "证书名称", 80, 100, w=120),
cell(2, "等级", 210, 100, w=40),
cell(3, "有效期", 260, 100, w=160),
cell(4, name, 80, 124, w=120),
cell(5, level, 210, 124, w=40),
cell(6, valid, 260, 124, w=160),
]
def run(
items: dict[UUID, dict[str, Any]],
results: dict[UUID, match.Result],
sents: list[qualification.Sentence],
library: list[Record],
deadline: date | None = DEADLINE,
) -> tuple[list[qualification.Hit], qualification.Coverage]:
return qualification.run(items, results, [doc(sents)] if sents else [], library, deadline)
def test_expired_from_bid_text_uses_deadline_not_today() -> None:
a, b = uuid4(), uuid4()
items = {
a: item("投标人须具有有效的安全生产许可证", ["qualification", "invalidity"]),
b: item("具有 ISO 9001 质量管理体系认证(2 分)", ["scoring"]),
}
sents = [
*cert_table("安全生产许可证", "—", "2023年11月16日至2026年11月15日"),
cell(7, "ISO9001质量管理体系认证证书", 80, 148, w=120),
cell(8, "—", 210, 148, w=40),
cell(9, "2025-03-10至2026-11-20", 260, 148, w=160), # 截止日当天到期仍算有效
]
hits, cov = run(items, {a: result("responded"), b: result("responded")}, sents, [])
(h,) = hits
assert h.code == "QUAL.EXPIRED" and h.item == a and h.severity == "fatal" and h.type == "qualification"
assert h.detail["source"] == "bid_text" and h.detail["issues"][0]["bid_valid_to"] == "2026-11-15"
assert "2026年11月15日" in h.explanation and "2026年11月20日" in h.explanation
assert {s.text for _, s in h.evidence} >= {"安全生产许可证", "2023年11月16日至2026年11月15日"}
assert "请" not in h.explanation # 只写事实,不写整改建议
assert cov.checked == 2 and cov.by_library == 0
assert "资质库还没有录入资质" in cov.to_api()["not_checked"][0]
# 没有截止日就不比有效期
hits, cov = run(items, {a: result("responded"), b: result("responded")}, sents, [], deadline=None)
assert hits == [] and cov.no_deadline
def test_library_only_lowers_severity_and_replaces_scanned_fallback() -> None:
a = uuid4()
items = {a: item("投标人须具有有效的安全生产许可证", ["qualification"])}
res = {a: result("scanned")}
hits, cov = run(
items,
res,
[para(1, "附件:资格证明文件(扫描件)")],
[rec("安全生产许可证", date(2026, 10, 31), kind="certificate")],
)
(h,) = hits
assert h.code == "QUAL.EXPIRED" and h.severity == "important" and h.detail["source"] == "library"
assert h.evidence == [] and h.detail["issues"][0]["library"]["valid_to"] == "2026-10-31"
assert "按资质库记录" in h.explanation
assert findings.spec(res[a], items[a]) is None # 扫描件兜底那条不再单独出
assert cov.by_library == 1
# 投标文件是文字版、确实没找到:保留「未找到响应」,只补一句资质库的情况
res = {a: result("not_found")}
hits, _ = run(items, res, [para(1, "目录")], [rec("安全生产许可证", date(2026, 10, 31), kind="certificate")])
spec = findings.spec(res[a], items[a])
assert hits == [] and spec is not None and spec.type == "unresponded" and "资质库里的" in spec.explanation
def test_bid_text_wins_over_library() -> None:
a = uuid4()
items = {a: item("具有 ISO 27001 信息安全管理体系认证证书(2 分)", ["scoring"])}
sents = cert_table("ISO27001信息安全管理体系认证", "—", "有效期至2026年11月15日")
hits, _ = run(
items, {a: result("responded")}, sents, [rec("ISO/IEC 27001 信息安全管理体系认证", date(2029, 11, 15))]
)
(h,) = hits
assert h.detail["source"] == "bid_text" and h.severity == "important" and "可能是旧证书" in h.explanation
# 投标文件里的有效期没过期:资质库里的旧记录不影响
sents = cert_table("ISO27001信息安全管理体系认证", "—", "2025-01-01至2028-01-01")
hits, _ = run(items, {a: result("responded")}, sents, [rec("ISO 27001", date(2025, 1, 1))])
assert hits == []
def test_level_low_and_uncomparable_levels() -> None:
a = uuid4()
items = {a: item("具有 CS3 级及以上信息系统建设和服务能力等级证书", ["qualification"])}
sents = cert_table("信息系统建设和服务能力等级证书", "CS2", "2024-01-01至2028-01-01")
(h,) = run(items, {a: result("responded")}, sents, [])[0]
assert h.code == "QUAL.LEVEL_LOW" and h.severity == "fatal" and "CS2" in h.explanation
sents = cert_table("信息系统建设和服务能力等级证书", "CS4", "2024-01-01至2028-01-01")
assert run(items, {a: result("responded")}, sents, [])[0] == []
# 资质库的等级写法认不出:不比较
lib = [rec("信息系统建设和服务能力等级证书", date(2028, 1, 1), level="良好", kind="certificate")]
assert run(items, {a: result("scanned")}, [para(1, "资格证明")], lib)[0] == []
lib = [rec("信息系统建设和服务能力等级证书", date(2028, 1, 1), level="CS2", kind="certificate")]
(h,) = run(items, {a: result("scanned")}, [para(1, "资格证明")], lib)[0]
assert h.code == "QUAL.LEVEL_LOW" and h.severity == "important" and h.detail["source"] == "library"
def test_not_covered_only_with_nonempty_library() -> None:
a = uuid4()
items = {a: item("具有 ISO 20000 信息技术服务管理体系认证(2 分)", ["scoring"])}
lib = [rec("ISO 9001 质量管理体系认证", date(2028, 1, 1))]
res = {a: result("scanned")}
(h,) = run(items, res, [para(1, "资格证明")], lib)[0]
assert h.code == "QUAL.NOT_COVERED" and h.severity == "important" and findings.spec(res[a], items[a]) is None
# 资质库是空的:不报,只写覆盖范围(QL3)
res = {a: result("scanned")}
hits, cov = run(items, res, [para(1, "资格证明")], [])
assert hits == [] and cov.library == 0
assert findings.spec(res[a], items[a]) is not None # 扫描件兜底照常出
# 资质库里有、有效:兜底说明里补一句是哪一张
res = {a: result("scanned")}
lib = [rec("ISO/IEC 20000-1 信息技术服务管理体系认证", date(2028, 1, 1))]
assert run(items, res, [para(1, "资格证明")], lib)[0] == []
spec = findings.spec(res[a], items[a])
assert spec is not None and "资质库里有《ISO/IEC 20000-1" in spec.explanation
def test_exclusions_requirement_copies_and_ambiguous_rows() -> None:
a, b, c = uuid4(), uuid4(), uuid4()
items = {
a: item("所投产品须具有节能产品认证证书", ["qualification", "invalidity"]),
b: item("项目负责人须持有 ISO 27001 审核员证书", ["scoring"]),
c: item("具有有效的 ISO 9001 质量管理体系认证证书(2 分)", ["scoring"]),
}
lib = [rec("ISO 20000", date(2028, 1, 1))]
# 响应表里照抄的要求不算证书事实;一行里两类证书时日期归属不清,不读
sents = [
para(1, "具有有效的ISO9001质量管理体系认证证书(2分),有效期至2025年1月1日"),
*cert_table("ISO9001、ISO27001认证证书", "—", "有效期至2025年1月1日"),
]
hits, cov = run(items, {a: result("scanned"), b: result("scanned"), c: result("responded")}, sents, lib)
assert hits == [] and cov.excluded == 2 and cov.checked == 1
def test_software_copyright_and_ccrc_subtype() -> None:
a, b = uuid4(), uuid4()
items = {
a: item("提供计算机软件著作权登记证书(每件 1 分)", ["scoring"]),
b: item("具有信息安全服务资质(安全集成)一级证书", ["scoring"]),
}
lib = [
rec("某平台软件 V2.0", None, kind="software_copyright"),
rec("CCRC 信息安全服务资质一级", date(2028, 1, 1), level="一级", kind="certificate"),
]
res = {a: result("scanned"), b: result("responded")}
(h,) = run(items, res, [para(1, "资质证明文件")], lib)[0]
assert h.item == b and h.code == "QUAL.NOT_COVERED" and h.severity == "hint" and "安全集成" in h.explanation
spec = findings.spec(res[a], items[a])
assert spec is not None and "1 件软件著作权" in spec.explanation # 软著不查有效期,只补说明
def test_coverage_counts_unrecognized_certificates_only() -> None:
a, b, c = uuid4(), uuid4(), uuid4()
items = {
a: item("提供有效的营业执照副本或事业单位法人证书", ["qualification"]),
b: item("具有某行业协会颁发的服务能力认证证书(2 分)", ["scoring"]),
c: item("具有类似项目业绩(每个 1 分)", ["scoring"]),
}
_, cov = run(items, {}, [], [])
assert (cov.unrecognized, cov.checked, cov.excluded) == (1, 0, 0)
assert any("1 条资质要求没认出" in x for x in cov.to_api()["not_checked"])
def test_scoring_items_not_searched_still_get_library_checks() -> None:
"""评分项没逐条找响应(results 里没有):资质库的过期照常提示;「未覆盖」不报(不知道投标人报不报这一项分)。"""
a, b = uuid4(), uuid4()
items = {
a: item("具有 ISO 27001 信息安全管理体系认证(2 分)", ["scoring"]),
b: item("具有 ISO 22301 业务连续性管理体系认证(1 分)", ["scoring"]),
}
lib = [rec("ISO/IEC 27001 信息安全管理体系认证", date(2026, 10, 31))]
(h,) = run(items, {}, [para(1, "商务部分")], lib)[0]
assert h.item == a and h.code == "QUAL.EXPIRED" and h.severity == "important" and h.detail["source"] == "library"
def test_levels_read_per_certificate_segment() -> None:
"""一条评分项里写了几类证书时,各自的等级从各自那一段读;大写数字的等级(贰级)也认。"""
itss, cs, eie = BY_KEY["itss"], BY_KEY["cs"], BY_KEY["eie"]
req = "企业资信:具有 ITSS 运行维护标准符合性证书的,三级得 1 分,二级及以上得 2 分;具有 CMMI 5 级证书的得 1 分"
assert level_rank(itss, qualification.segment(itss, req)) == (7, "三级")
assert qualification.segment(cs, req) == text_of(req) # 没有这一类:全文
assert level_rank(eie, "电子与智能化工程专业承包贰级")[0] == level_rank(eie, "二级")[0] # type: ignore[index]
def test_one_issue_per_certificate_type() -> None:
"""一条评分项里两类证书:一个过期、一个等级不够,分成两个问题,标题写证书名。"""
a = uuid4()
items = {a: item("企业资信:ITSS 运维证书三级及以上得 1 分;CS3 及以上得 2 分", ["scoring"])}
sents = [
*cert_table("信息技术服务标准(ITSS)运行维护符合性证书", "二级", "2023-06-01至2026-05-31"),
cell(7, "信息系统建设和服务能力等级证书", 80, 148, w=120),
cell(8, "CS2", 210, 148, w=40),
cell(9, "2024-12-31至2028-12-30", 260, 148, w=160),
]
hits, _ = run(items, {a: result("responded")}, sents, [])
assert sorted((h.code, h.target) for h in hits) == [("QUAL.EXPIRED", f"{a}:itss"), ("QUAL.LEVEL_LOW", f"{a}:cs")]
assert {h.title for h in hits} == {
"证书在投标截止日已过期:ITSS 信息技术服务运行维护标准符合性证书",
"证书等级低于要求:信息系统建设和服务能力等级",
}
def test_level_column_wins_over_certificate_number() -> None:
a = uuid4()
items = {a: item("具有 CS3 级及以上信息系统建设和服务能力等级证书", ["qualification"])}
sents = [
cell(1, "证书名称", 80, 100, w=120),
cell(2, "证书编号", 210, 100, w=100),
cell(3, "等级", 320, 100, w=40),
cell(4, "有效期", 370, 100, w=120),
cell(5, "信息系统建设和服务能力等级证书", 80, 124, w=120),
cell(6, "CS3-3201-2024000077", 210, 124, w=100),
cell(7, "CS2", 320, 124, w=40),
cell(8, "2024-12-31至2028-12-30", 370, 124, w=120),
]
(h,) = run(items, {a: result("responded")}, sents, [])[0]
assert h.code == "QUAL.LEVEL_LOW" and "「CS2」" in h.explanation