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

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