Files
bid-doctor/server/src/bid_doctor/check/findings.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

124 lines
6.2 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.

"""由每条清单项的检查结论生成问题(FR-M3-03、m3-plan §2.3)。
- 级别从清单项算,不让模型定(参考项目让模型定级别,模型可以把高危项降成低危);
- 说明只写事实和出处,不写整改建议(报价类问题的「建议」会写出数字,红线 6);
- 「已响应」不生成问题,结论留在 check_item_result 里。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from bid_doctor.check.match import Result
FATAL_CATEGORIES = {"qualification", "invalidity"}
REASONS = {
"evidence_missing": "没有给出投标原文",
"sid_format": "引用的句子编号写法不对",
"sid_unknown": "引用的句子不在候选范围内",
"quote_not_original": "引文和投标原文对不上",
"quote_too_short": "引文太短,无法核对",
"missing_answer": "这一条没有得到判断结果",
"model_error": "模型服务暂时不可用",
"over_budget": "超出本次检查的预算",
"response_mismatch": "响应格写的是别的内容",
}
@dataclass(frozen=True, slots=True)
class Spec:
type: str
severity: str
title: str
explanation: str
detail: dict[str, Any]
def severity(item: dict[str, Any]) -> str:
"""★ 条款、法规列明或原文写明无效的、资格条件 → 致命;▲ 参数、评分项(缺证明材料)→ 重要;其余 → 提示。"""
cats = set(item.get("categories") or [])
if item.get("importance") == "substantive" or cats & FATAL_CATEGORIES:
return "fatal"
if item.get("importance") == "important" or "scoring" in cats:
return "important"
return "hint"
def _pages(pages: list[int]) -> str:
return "、".join(f"第 {p} 页" for p in pages)
def spec(result: Result, item: dict[str, Any]) -> Spec | None:
out = _spec(result, item)
note = result.detail.get("qualification_note")
if out is None or not note:
return out
return Spec(out.type, out.severity, out.title, f"{out.explanation}{note}", out.detail)
def _spec(result: Result, item: dict[str, Any]) -> Spec | None:
if result.detail.get("superseded_by"):
return None # 资质匹配出了更具体的问题(check/qualification.py),扫描件兜底那条不再单独出
sev = severity(item)
name = str(item.get("title") or "")
base = {"importance_basis": item.get("importance_basis"), **result.detail}
scope = "、".join(result.detail.get("search_scope") or [])
if result.verdict in ("responded", "implicit_accepted"):
return None
if result.verdict == "implicit_review":
text = (
"招标文件规定未列入偏离表的视为接受,投标文件里没有找到对这条要求的明确响应。"
"这是实质性要求,请人工核对所投内容确实满足。"
)
return Spec("manual_review", "hint", f"请人工核对:{name}", text, {**base, "mode": "implicit"})
if result.verdict == "not_found" and result.evidence_status == "unverified":
claimed = result.detail.get("claimed_pages") or []
where = f"{_pages(claimed)}有响应" if claimed else "有响应"
why = REASONS.get(result.reason_code or "", result.reason_code or "")
text = f"模型认为投标文件{where},但出处没能核对({why}),按「未找到响应」处理。请人工核对。"
return Spec("unresponded", sev, f"未找到响应:{name}", text, {**base, "evidence_status": "unverified"})
if result.verdict == "not_found" and result.detail.get("mismatch"):
text = (
f"投标文件照抄了这条要求,但同一行的响应格写的是「{result.detail['mismatch'][:40]}」,"
"和这条要求对不上,像是别的条目的内容。请核对。"
)
return Spec("unresponded", sev, f"响应可能对不上:{name}", text, base)
if result.verdict == "not_found":
text = f"在投标文件({scope})中没有找到对这条要求的响应。" if scope else "没有找到对这条要求的响应。"
return Spec("unresponded", sev, f"未找到响应:{name}", text, base)
if result.verdict == "negative_deviation" and result.detail.get("deviation_unclear"):
text = (
f"这一行的偏离栏写的是「{result.detail.get('direct_read')}」,不是明确的「无偏离 / 满足」,"
"请核对是否满足这条要求。"
)
return Spec("negative_deviation", "important" if sev == "fatal" else sev, f"偏离栏需核对:{name}", text, base)
if result.verdict == "negative_deviation":
cell = result.detail.get("direct_read")
text = (
f"投标文件这一行的偏离 / 响应栏写着「{cell}」,没有找到说明。"
if cell
else "投标文件写明了偏离或不满足,没有找到说明。"
)
return Spec("negative_deviation", sev, f"负偏离未说明:{name}", text, base)
if result.verdict == "suspected_unmet":
value = result.detail.get("response_value")
text = (
f"投标文件给出的值({value})按要求值判断可能不满足。"
if value
else "投标文件给出的值按要求值判断可能不满足。"
)
return Spec("suspected_unmet", sev, f"疑似不满足:{name}", text, base)
if result.verdict == "no_value":
text = "投标文件只写了满足或响应,没有给出要求里要的具体值或材料。"
return Spec("suspected_unmet", "hint", f"未给出具体值:{name}", text, {**base, "kind": "no_value"})
if result.verdict == "scanned":
total = result.detail.get("scanned_total") or 0
text = (
f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"
"材料可能在扫描件页上,请人工核对。"
)
return Spec("manual_review", "hint", f"请人工核对(可能在扫描件里):{name}", text, base)
why = REASONS.get(result.reason_code or "", result.reason_code or "")
return Spec("manual_review", sev, f"未判定:{name}", f"这条要求本次没有完成判断({why}),请人工核对。", base)