Files
bid-doctor/server/tests/test_qualification_scan.py
YANG JIANKUAN 3344f09563 feat(ocr): 扫描件与图片识别——OCR 进文字层、资质库上传识别、大图浏览
计划见 docs/architecture/vision-plan.md(VS1–VS13,docs/decisions.md)。

- 验证:OCR(RapidOCR + PP-OCRv6 small,CPU)和 DeepSeek Flash 看图在国动投标文件
  和 7 张证书上初测(tech-validation §7,代码 spikes/s5-ocr/)
- OCR 服务 services/ocr(镜像、Compose 服务):整页转正(含 180°)、红章附近按红色通道重读
- 扫描页和插图识别后并入文字层 v6,句子标 source=ocr;出处标「OCR」(迁移 0013);
  依据识别文字的问题降级,规则不查扫描附件;国动端到端见 check-eval §16
- 资质库上传识别:添加资质对话框最上面传证书照片,识别后填表,只预填和 OCR 文字
  对得上的字段;识别值留痕、照片挂成附件(迁移 0014,提示词 vision.cert_fields v1)
- 资质库改为查看详情(只读键值 + 附件缩略图),附件用大图浏览控件看,不开新标签页
  (设计规范 §7.23);PDF 附件按页出预览图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:50:13 +08:00

271 lines
12 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.

"""资质库上传识别(vision-plan 第 2 步):上传 → 识别 → 核对保存;识别值留痕、原件挂成附件;
打码、置信度低、对不上的字段不当成确定值;重复证书、主体不一致、只写了有效期长度的提醒;限次;没开 OCR 时说明。
OCR 服务换成桩(按图片返回预先准备的行),模型用确定性的假模型(按「证书编号:」这类写法取值)。
"""
from __future__ import annotations
import io
from datetime import date
from typing import Any
from uuid import UUID
import httpx
import pytest
from PIL import Image
from bid_doctor.document.pipeline import ocr
from bid_doctor.qualification import recognize
from bid_doctor.qualification import service as qualifications
from tests.conftest import User
def png(w: int = 800, h: int = 560) -> bytes:
buf = io.BytesIO()
Image.new("RGB", (w, h), "white").save(buf, "PNG")
return buf.getvalue()
def webp() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (400, 300), "white").save(buf, "WEBP")
return buf.getvalue()
def line(text: str, y: float, score: float = 0.99) -> dict[str, Any]:
return {
"box": [[40, y], [600, y], [600, y + 30], [40, y + 30]],
"upright": [40, y, 600, y + 30],
"text": text,
"score": score,
}
CERT = [
line("信息系统建设和服务能力等级证书", 20),
line("企业名称:测试集成公司", 80),
line("证书编号:CS3-3201-2024-0417", 120),
line("等级:CS3", 160),
line("发证机构:中国电子信息行业联合会", 200),
line("发证日期:2024年04月18日", 240),
line("有效期至:2028年04月17日", 280),
]
@pytest.fixture
def fake_ocr(monkeypatch: pytest.MonkeyPatch) -> list[list[dict[str, Any]]]:
"""打开 OCR,识别换成桩:按调用顺序依次返回准备好的行(每页一组)。"""
queue: list[list[dict[str, Any]]] = []
monkeypatch.setattr(ocr, "enabled", lambda: True)
async def recognize_(client: httpx.AsyncClient, image: bytes, *, full_page: bool) -> dict[str, Any]:
assert full_page
return {"rotation": 0, "lines": queue.pop(0) if queue else [], "stamps": []}
monkeypatch.setattr(ocr, "recognize", recognize_)
return queue
async def upload(u: User, filename: str, data: bytes) -> str:
r = await u.post("/uploads", json={"filename": filename, "size_bytes": len(data), "purpose": "qualification_file"})
assert r.status_code == 201, r.text
up = r.json()
assert (await u.put(f"/uploads/{up['id']}/parts/1", content=data)).status_code == 204
done = await u.post(f"/uploads/{up['id']}/complete")
assert done.status_code == 200, done.text
return up["id"]
async def scan(u: User, filename: str = "cs3.png", data: bytes | None = None) -> dict[str, Any]:
r = await u.post("/qualification-scans", json={"upload_id": await upload(u, filename, data or png())})
assert r.status_code == 201, r.text
assert r.json()["status"] == "queued"
await recognize.run(UUID(u.tenant_id), UUID(r.json()["id"])) # 测试进程里直接跑,不经过 worker
return (await u.get(f"/qualification-scans/{r.json()['id']}")).json()
async def test_scan_then_save(user: User, fake_ocr: list[list[dict[str, Any]]]) -> None:
fake_ocr.append(CERT)
s = await scan(user)
assert s["status"] == "succeeded" and s["error"] is None
(page,) = s["pages"]
assert (page["width"], page["height"]) == (800, 560) and "sig=" in page["url"]
(c,) = s["certificates"]
assert c["category"] == "enterprise_qualification" and c["kind"] == "certificate" and c["page"] == 1
f = c["fields"]
assert f["cert_no"]["value"] == "CS3-3201-2024-0417" and f["cert_no"]["check"] == "ok"
assert f["valid_to"]["day"] == "2028-04-17" and f["valid_to"]["boxes"] == [[40, 280, 600, 310]]
assert c["notes"] == [] and c["existing"] is None # 证书主体就是本企业
# 页面预览图(签名地址,不带鉴权头)
img = await user.client.get("http://test" + page["url"].split("8009", 1)[-1])
assert img.status_code == 200 and img.headers["content-type"] == "image/jpeg"
body = {
"name": c["name"],
"kind": c["kind"],
"level": f["level"]["value"],
"cert_no": f["cert_no"]["value"],
"issuer": f["issuer"]["value"],
"holder": f["holder"]["value"],
"valid_from": None,
"valid_to": f["valid_to"]["day"],
"scan": {"scan_id": s["id"], "index": 0},
}
r = await user.post("/qualifications", json=body)
assert r.status_code == 201, r.text
q = r.json()
assert q["holder"] == "测试集成公司" and q["recognized"] is True and len(q["files"]) == 1
assert q["files"][0]["filename"] == "cs3.png"
from sqlalchemy import select
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.qualification.models import Qualification
ctx = TenantContext(tenant_id=UUID(user.tenant_id), user_id=UUID(str(user.me["user"]["id"])))
async with tenant_tx(ctx) as db:
row = (await db.execute(select(Qualification).where(Qualification.id == UUID(q["id"])))).scalar_one()
assert row.ai_values is not None and row.ai_values["scan_id"] == s["id"]
assert row.ai_values["fields"]["cert_no"] == {"value": "CS3-3201-2024-0417", "check": "ok"}
# 同一张证书再识别一次:提示资质库里已有编号相同的
fake_ocr.append(CERT)
again = await scan(user)
assert again["certificates"][0]["existing"] == {"id": q["id"], "name": q["name"]}
# 用新识别结果更新已有的一条:同一个原件不重复挂
r = await user.put(f"/qualifications/{q['id']}", json={**body, "scan": {"scan_id": again["id"], "index": 0}})
assert r.status_code == 200 and len(r.json()["files"]) == 1
assert (await user.delete(f"/qualification-scans/{again['id']}")).status_code == 204
assert (await user.get(f"/qualification-scans/{again['id']}")).status_code == 404
async def test_masked_low_confidence_and_mismatched_fields(user: User, fake_ocr: list[list[dict[str, Any]]]) -> None:
fake_ocr.append(
[
line("职业技能等级证书", 20),
line("姓名:■■■", 80),
line("证书编号:FRX11032M897", 120, score=0.71), # 马赛克上认出的字
line("发证日期:2024年02月19日", 160),
]
)
(c,) = (await scan(user))["certificates"]
assert c["category"] == "personnel" and c["kind"] == "other" and c["notes"] == [{"code": "personnel"}]
assert c["masked"] == ["holder"] and "holder" not in c["fields"]
assert c["fields"]["cert_no"]["check"] == "low_conf"
async def test_holder_mismatch_and_validity_suggestion(user: User, fake_ocr: list[list[dict[str, Any]]]) -> None:
fake_ocr.append(
[
line("信息安全服务资质证书", 20),
line("企业名称:南京某某信息技术有限公司", 80),
line("证书编号:CCRC-2025-ISV-SI-0001", 120),
line("发证日期:2025-08-27", 160),
]
)
(c,) = (await scan(user))["certificates"]
assert c["notes"] == [{"code": "holder_mismatch", "holder": "南京某某信息技术有限公司", "tenant": "测试集成公司"}]
assert c["suggest"] == {} # 没写有效期长度,不推算
async def test_scan_needs_ocr_and_is_rate_limited(user: User, monkeypatch: pytest.MonkeyPatch) -> None:
r = await user.post("/qualification-scans", json={"upload_id": await upload(user, "a.png", png())})
assert r.status_code == 503 and r.json()["code"] == "ocr_unavailable"
monkeypatch.setattr(ocr, "enabled", lambda: True)
monkeypatch.setattr(qualifications, "SCAN_DAILY_LIMIT", 1)
assert (
await user.post("/qualification-scans", json={"upload_id": await upload(user, "a.webp", webp())})
).status_code == 201
r = await user.post("/qualification-scans", json={"upload_id": await upload(user, "b.png", png())})
assert r.status_code == 429 and r.json()["code"] == "rate_limited"
async def test_failed_ocr_is_reported(user: User, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ocr, "enabled", lambda: True)
async def down(client: httpx.AsyncClient, image: bytes, *, full_page: bool) -> dict[str, Any]:
raise ocr.OcrUnavailable("connection refused")
monkeypatch.setattr(ocr, "recognize", down)
s = await scan(user)
assert s["status"] == "failed" and s["error"]["code"] == "ocr_unavailable" and s["certificates"] == []
# —— 校验(不经过数据库) ——
def L(text: str, score: float = 0.99) -> dict[str, Any]:
return {"text": text, "score": score, "box": [[0, 0], [10, 0], [10, 10], [0, 10]]}
def test_check_field() -> None:
lines = {"L1": L("软件名称:建筑工程结构优化设计辅助系统"), "L2": L("V1.0"), "L3": L("登记日期:2024年05月28日")}
check = recognize.check_field
assert check("software", "建筑工程结构优化设计辅助系统 V1.0", ["L1", "L2"], lines, "software_copyright") == "ok"
assert check("issued_on", "2024-5-28", ["L3"], lines, "software_copyright") == "date_ok"
assert check("issued_on", "2024年05月29日", ["L3"], lines, "software_copyright") == "mismatch"
assert check("issued_on", "五月", ["L3"], lines, "software_copyright") == "bad_format"
assert check("cert_no", "X", ["L9"], lines, "software_copyright") == "bad_line"
assert check("holder", "甲方", ["L1"], {"L1": L("甲方(用人单位):")}, "personnel") == "bad_format"
assert check("holder", "张麒征", ["L1"], {"L1": L("张麒征", 0.69)}, "personnel") == "low_conf"
def test_suggest_valid_to() -> None:
assert recognize.suggest_valid_to(date(2025, 8, 27), "五年") == date(2030, 8, 26)
assert recognize.suggest_valid_to(date(2024, 2, 29), "3年") == date(2027, 2, 27)
assert recognize.suggest_valid_to(None, "五年") is None
assert recognize.suggest_valid_to(date(2025, 8, 27), "长期") is None
def test_build_names_and_numbers() -> None:
"""名称带上软件名;软著用登记号;两张证书分开。"""
lines = {
"L1": L("计算机软件著作权登记证书"),
"L2": L("软件名称:宇通R系列手机通信软件V1.0"),
"L3": L("登记号:2015SR022340"),
}
raw = {
"certificates": [
{
"category": "software_copyright",
"fields": [
{"field": "name", "value": "计算机软件著作权登记证书", "lines": ["L1"]},
{"field": "software", "value": "宇通R系列手机通信软件V1.0", "lines": ["L2"]},
{"field": "reg_no", "value": "2015SR022340", "lines": ["L3"]},
],
"masked": [],
},
{"category": "product", "fields": [], "masked": []},
]
}
a, b = recognize.build(1, raw, lines, "某公司", [])
assert a["name"] == "计算机软件著作权登记证书(宇通R系列手机通信软件V1.0)" and a["cert_no_field"] == "reg_no"
assert a["kind"] == "software_copyright" and b["notes"] == [{"code": "product"}]
async def test_attachment_previews(user: User) -> None:
"""附件预览:图片一张、PDF 每页一张;签名地址不带鉴权头也能打开(<img> 用)。"""
from tests.test_qualification_flow import attach
q = (await user.post("/qualifications", json={"name": "ISO 9001", "kind": "system_cert", "cert_no": "Q1"})).json()
pdf = io.BytesIO()
pages = [Image.new("RGB", (600, 800), "white"), Image.new("RGB", (800, 600), "white")]
pages[0].save(pdf, "PDF", save_all=True, append_images=pages[1:])
await attach(user, q["id"], "a.png", png())
r = await attach(user, q["id"], "b.pdf", pdf.getvalue())
files = r.json()["files"]
counts = []
for f in files:
p = (await user.get(f"/qualifications/{q['id']}/files/{f['id']}/previews")).json()
counts.append(len(p["pages"]))
img = await user.client.get("http://test" + p["pages"][-1]["url"].split("8009", 1)[-1])
assert img.status_code == 200 and img.headers["content-type"] == "image/jpeg"
assert counts == [1, 2]
bad = p["pages"][0]["url"].replace("sig=", "sig=x")
assert (await user.client.get("http://test" + bad.split("8009", 1)[-1])).status_code == 404