计划见 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>
271 lines
12 KiB
Python
271 lines
12 KiB
Python
"""资质库上传识别(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
|