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

@@ -18,7 +18,7 @@ PLATFORM_FORMATS = {"kedt": "苏采云(江苏政府采购)"}
@dataclass(frozen=True, slots=True)
class Detected:
format: str # pdf / docx / doc / ofd
format: str # pdf / docx / doc / ofd;资质附件另有 jpeg / png
page_count: int | None # 只有 PDF 能直接读出页数
extension_mismatch: bool
@@ -43,6 +43,23 @@ def detect(data: bytes, filename: str) -> Detected:
return Detected(format=fmt, page_count=pages, extension_mismatch=ext not in EXTENSIONS.get(fmt, set()))
ATTACHMENT_EXTENSIONS = {"pdf": {"pdf"}, "jpeg": {"jpg", "jpeg"}, "png": {"png"}}
def detect_attachment(data: bytes, filename: str) -> Detected:
"""资质附件(证书扫描件):PDF、JPG、PNG,同样按文件头认。"""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if data.startswith(b"%PDF"):
fmt, pages = "pdf", _pdf_pages(data)
elif data.startswith(b"\xff\xd8\xff"):
fmt, pages = "jpeg", 1
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
fmt, pages = "png", 1
else:
raise AppError("file_format_unsupported", "证书附件支持 PDF、JPG、PNG")
return Detected(format=fmt, page_count=pages, extension_mismatch=ext not in ATTACHMENT_EXTENSIONS[fmt])
def _pdf_pages(data: bytes) -> int:
try:
doc = pdfium.PdfDocument(data)

View File

@@ -22,9 +22,10 @@ from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.core.storage import storage, upload_prefix
from bid_doctor.core.valkey import valkey
from bid_doctor.document.formats import detect
from bid_doctor.document.formats import detect, detect_attachment
TTL_S = 24 * 3600
ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024 # 资质附件(qualification-plan §2.3)
@dataclass(slots=True)
@@ -55,6 +56,8 @@ async def _limits(ctx: TenantContext) -> dict[str, Any]:
async def create(
ctx: TenantContext, filename: str, size_bytes: int, purpose: str, sha256: str | None
) -> dict[str, Any]:
if purpose == "qualification_file" and size_bytes > ATTACHMENT_MAX_BYTES:
raise AppError("validation_failed", "证书附件最大 20MB")
limits = await _limits(ctx)
if size_bytes > limits["max_file_bytes"]:
raise AppError("plan_limit_exceeded", f"单个文件最大 {limits['max_file_bytes'] // (1024 * 1024)}MB")
@@ -121,9 +124,11 @@ async def complete(ctx: TenantContext, upload_id: UUID) -> dict[str, Any]:
digest = hashlib.sha256(data).hexdigest()
if meta.sha256 and meta.sha256 != digest:
raise AppError("upload_checksum_mismatch")
d = detect(data, meta.filename) # 格式、加密、损坏都在这里拦下,不建任务、不扣点
# 格式、加密、损坏都在这里拦下,不建任务、不扣点
attachment = meta.purpose == "qualification_file"
d = detect_attachment(data, meta.filename) if attachment else detect(data, meta.filename)
limits = await _limits(ctx)
if d.page_count and d.page_count > limits["max_pages"]:
if not attachment and d.page_count and d.page_count > limits["max_pages"]:
raise AppError("plan_limit_exceeded", f"单个文件最多 {limits['max_pages']} 页,这份有 {d.page_count} 页")
storage().put(f"{prefix}/file", seal_object(dk, data, f"{prefix}/file"))
for n in range(1, meta.part_count + 1): # 分片用完即删