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>
This commit is contained in:
259
server/tests/test_ocr.py
Normal file
259
server/tests/test_ocr.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""OCR 补全(vision-plan 第 1 步):扫描件招标文件能解析、出处标 OCR;扫描件投标文件能检查;
|
||||
OCR 服务挂了退回额度;切句规则。
|
||||
|
||||
OCR 服务换成桩:把夹具 PDF 渲染成纯图片 PDF(模拟扫描件),桩按原文件文字层的位置返回「识别结果」
|
||||
(等于一次完美的 OCR)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pypdfium2 as pdfium
|
||||
import pytest
|
||||
|
||||
from bid_doctor.document.pipeline import ocr, text_layer
|
||||
from bid_doctor.document.pipeline.text_layer import Page, Sentence
|
||||
from tests.conftest import FIXTURES, User, balance, run_parse, upload
|
||||
from tests.test_check_flow import run_check
|
||||
|
||||
DPI = 200
|
||||
S = DPI / 72
|
||||
|
||||
|
||||
def scanned(fixture: str) -> tuple[bytes, dict[int, dict[str, Any]]]:
|
||||
"""夹具 → (纯图片 PDF,每页的 OCR 结果)。图片 PDF 的页面尺寸和原文件一样,坐标可以直接对照。"""
|
||||
src = (FIXTURES / fixture).read_bytes()
|
||||
doc = pdfium.PdfDocument(src)
|
||||
images = [doc[i].render(scale=2).to_pil().convert("RGB") for i in range(len(doc))] # pyright: ignore[reportArgumentType]
|
||||
buf = io.BytesIO()
|
||||
images[0].save(buf, "PDF", save_all=True, append_images=images[1:], resolution=144)
|
||||
results: dict[int, dict[str, Any]] = {}
|
||||
for p in text_layer.extract("src", src):
|
||||
lines = []
|
||||
for x in p.sentences:
|
||||
x0, t, x1, b = (
|
||||
min(q[0] for q in x.quads),
|
||||
min(q[1] for q in x.quads),
|
||||
max(q[2] for q in x.quads),
|
||||
max(q[3] for q in x.quads),
|
||||
)
|
||||
box = [[x0 * S, t * S], [x1 * S, t * S], [x1 * S, b * S], [x0 * S, b * S]]
|
||||
lines.append({"box": box, "upright": [x0 * S, t * S, x1 * S, b * S], "text": x.text, "score": 0.98})
|
||||
results[p.page_no] = {"rotation": 0, "lines": lines, "stamps": []}
|
||||
return buf.getvalue(), results
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_ocr(monkeypatch: pytest.MonkeyPatch) -> dict[int, dict[str, Any]]:
|
||||
"""打开 OCR,渲染和识别都换成桩:渲染只记页码,识别按页码返回预先准备的结果。"""
|
||||
pages: dict[int, dict[str, Any]] = {}
|
||||
monkeypatch.setattr(ocr, "enabled", lambda: True)
|
||||
monkeypatch.setattr(ocr, "_render", lambda pdf, page_no, region, dpi: str(page_no).encode())
|
||||
|
||||
async def recognize(client: httpx.AsyncClient, image: bytes, *, full_page: bool) -> dict[str, Any]:
|
||||
return pages.get(int(image.decode()), {"rotation": 0, "lines": [], "stamps": []})
|
||||
|
||||
monkeypatch.setattr(ocr, "recognize", recognize)
|
||||
return pages
|
||||
|
||||
|
||||
async def scanned_tender(u: User, fake: dict[int, dict[str, Any]]) -> tuple[str, str]:
|
||||
pdf, results = scanned("tender-mini.pdf")
|
||||
fake.update(results)
|
||||
project = (await u.post("/projects", json={})).json()
|
||||
done = await upload(u, "tender-scan.pdf", pdf)
|
||||
r = await u.post(f"/projects/{project['id']}/documents", json={"upload_id": done.json()["id"], "role": "tender"})
|
||||
assert r.status_code == 201, r.text
|
||||
return project["id"], r.json()["id"]
|
||||
|
||||
|
||||
async def test_scanned_tender_is_parsed_with_ocr_citations(user: User, fake_ocr: dict[int, dict[str, Any]]) -> None:
|
||||
project_id, doc_id = await scanned_tender(user, fake_ocr)
|
||||
job = await run_parse(user, doc_id)
|
||||
assert job["status"] == "succeeded", job
|
||||
assert job["result"]["items_added"] == 3 # 和文字版的 tender-mini 一样
|
||||
|
||||
items = (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]
|
||||
formal = [i for i in items if i["zone"] == "formal"]
|
||||
assert len(formal) == 3
|
||||
for it in formal:
|
||||
(c,) = it["citations"]
|
||||
assert c["ocr"] is True and c["quote"].startswith("★") and c["quads"]
|
||||
|
||||
pages = (await user.get(f"/documents/{doc_id}/pages")).json()["items"]
|
||||
assert [p["text_source"] for p in pages] == ["ocr"]
|
||||
|
||||
|
||||
async def test_scanned_tender_without_ocr_still_fails_and_refunds(user: User) -> None:
|
||||
"""没开 OCR(dev 默认):扫描件照旧按「识别不出文字」失败,额度退回。"""
|
||||
pdf, _ = scanned("tender-mini.pdf")
|
||||
project = (await user.post("/projects", json={})).json()
|
||||
done = await upload(user, "tender-scan.pdf", pdf)
|
||||
r = await user.post(f"/projects/{project['id']}/documents", json={"upload_id": done.json()["id"], "role": "tender"})
|
||||
job = await run_parse(user, r.json()["id"])
|
||||
assert job["status"] == "failed" and job["error"]["code"] == "ocr_required" and job["error"]["refunded"]
|
||||
assert "识别不出文字" in job["error"]["message"]
|
||||
|
||||
|
||||
async def test_ocr_unavailable_fails_and_refunds(user: User, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
pdf, _ = scanned("tender-mini.pdf")
|
||||
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)
|
||||
project = (await user.post("/projects", json={})).json()
|
||||
done = await upload(user, "tender-scan.pdf", pdf)
|
||||
r = await user.post(f"/projects/{project['id']}/documents", json={"upload_id": done.json()["id"], "role": "tender"})
|
||||
job = await run_parse(user, r.json()["id"])
|
||||
assert job["status"] == "failed" and job["error"]["code"] == "ocr_unavailable" and job["error"]["refunded"]
|
||||
assert await balance(user) == 3
|
||||
|
||||
|
||||
async def test_scanned_bid_is_checked(user: User, fake_ocr: dict[int, dict[str, Any]]) -> None:
|
||||
"""整份扫描的投标文件以前报 bid_text_missing;识别之后照常检查,覆盖范围写明哪些是识别文字。"""
|
||||
project_id, tender_id = await scanned_tender(user, fake_ocr)
|
||||
assert (await run_parse(user, tender_id))["status"] == "succeeded"
|
||||
pdf, results = scanned("bid-mini-v1.pdf")
|
||||
fake_ocr.clear()
|
||||
fake_ocr.update(results)
|
||||
done = await upload(user, "bid-scan.pdf", pdf)
|
||||
body = {"upload_id": done.json()["id"], "role": "bid_volume", "volume_kind": "technical"}
|
||||
bid = (await user.post(f"/projects/{project_id}/documents", json=body)).json()
|
||||
run = await run_check(user, project_id, [bid["id"]])
|
||||
assert run["status"] == "succeeded", run
|
||||
assert any("识别出来的(OCR)" in x for x in run["coverage"]["checked"])
|
||||
results_ = (await user.get(f"/check-runs/{run['id']}/item-results")).json()["items"]
|
||||
responded = [r for r in results_ if r["verdict"] == "responded"]
|
||||
assert responded and all(c["ocr"] for r in responded for c in r["bid_citations"])
|
||||
|
||||
|
||||
# —— 切句(不经过数据库) ——
|
||||
|
||||
|
||||
def _line(text: str, x0: float, top: float, x1: float, h: float = 30, score: float = 0.99) -> dict[str, Any]:
|
||||
return {
|
||||
"box": [[x0, top], [x1, top], [x1, top + h], [x0, top + h]],
|
||||
"upright": [x0, top, x1, top + h],
|
||||
"text": text,
|
||||
"score": score,
|
||||
}
|
||||
|
||||
|
||||
def _page(sentences: list[Sentence] | None = None) -> Page:
|
||||
return Page(page_no=3, width=595, height=842, rotation=0, char_count=0, sentences=sentences or [], image_cover=1.0)
|
||||
|
||||
|
||||
def test_ocr_sentences_follow_text_layer_rules() -> None:
|
||||
page = _page()
|
||||
res = {
|
||||
"rotation": 0,
|
||||
"stamps": [[1000, 1000, 1200, 1200]],
|
||||
"lines": [
|
||||
_line("1.1 项目名称:某某系统建设项目,本项目由甲方负责组织实施并按", 200, 300, 1450),
|
||||
_line("期完成验收。", 200, 340, 500),
|
||||
_line("甲方:", 200, 450, 300),
|
||||
_line("南京某某科技有限公司", 320, 450, 800),
|
||||
_line("序号", 200, 600, 280),
|
||||
_line("名称", 900, 600, 980),
|
||||
_line("京长卿", 1050, 1050, 1150, score=0.999), # 章上的字
|
||||
_line("— 3 —", 700, 2280, 800),
|
||||
],
|
||||
}
|
||||
ocr.add_sentences("doc", page, res, (0.0, 0.0), S, full_page=True)
|
||||
texts = [(x.text, x.location) for x in page.sentences]
|
||||
assert texts == [
|
||||
("1.1 项目名称:某某系统建设项目,本项目由甲方负责组织实施并按期完成验收。", "body"),
|
||||
("甲方:南京某某科技有限公司", "body"),
|
||||
("序号", "body"),
|
||||
("名称", "body"),
|
||||
("— 3 —", "header_footer"),
|
||||
]
|
||||
first = page.sentences[0]
|
||||
assert first.key == "doc:p3:s1" and first.source == "ocr" and first.conf == 0.99 and len(first.quads) == 2
|
||||
assert first.quads[0] == [round(200 / S, 2), round(300 / S, 2), round(1450 / S, 2), round(330 / S, 2)]
|
||||
assert page.ocr_chars > 0 and page.text_source == "ocr"
|
||||
|
||||
|
||||
def test_ocr_keeps_text_layer_numbering_and_drops_duplicates() -> None:
|
||||
"""扫描页上方的文字说明在文字层里已经有了:OCR 认出的同一行去掉;OCR 句接在后面编号。"""
|
||||
caption = Sentence(key="doc:p3:s1", page_no=3, seq=1, text="合同扫描件第 1 页", quads=[[72, 50, 300, 66]])
|
||||
page = _page([caption])
|
||||
res = {
|
||||
"rotation": 0,
|
||||
"lines": [
|
||||
_line("合同扫描件第1页", 72 * S, 50 * S, 300 * S, h=16 * S),
|
||||
_line("软件开发服务合同", 200, 400, 700),
|
||||
],
|
||||
}
|
||||
ocr.add_sentences("doc", page, res, (0.0, 0.0), S, full_page=True)
|
||||
assert [(x.key, x.text, x.source) for x in page.sentences] == [
|
||||
("doc:p3:s1", "合同扫描件第 1 页", "text"),
|
||||
("doc:p3:s2", "软件开发服务合同", "ocr"),
|
||||
]
|
||||
|
||||
|
||||
def test_figure_labels_are_separate_sentences() -> None:
|
||||
"""插图里的字是一个个标签,各自成句,坐标加上图片在页面上的位置。"""
|
||||
page = _page()
|
||||
res = {"rotation": 0, "lines": [_line("业务应用层", 10, 10, 200), _line("统一身份认证", 250, 10, 450)]}
|
||||
ocr.add_sentences("doc", page, res, (100.0, 200.0), S, full_page=False)
|
||||
assert [x.text for x in page.sentences] == ["业务应用层", "统一身份认证"]
|
||||
assert page.sentences[0].quads[0][:2] == [round(100 + 10 / S, 2), round(200 + 10 / S, 2)]
|
||||
assert page.text_source == "ocr" # 这页本来就没有文字(char_count = 0)
|
||||
|
||||
|
||||
def test_rotated_page_uses_upright_order_and_original_coordinates() -> None:
|
||||
"""横放的扫描页:按转正后的坐标排顺序,出处框用原图坐标。"""
|
||||
page = _page()
|
||||
# 原图里是竖排的两行(转正 270°):upright 里上下排列
|
||||
res = {
|
||||
"rotation": 270,
|
||||
"lines": [
|
||||
{
|
||||
"box": [[300, 900], [300, 300], [330, 300], [330, 900]],
|
||||
"upright": [100, 300, 700, 330],
|
||||
"text": "资产负债表",
|
||||
"score": 1.0,
|
||||
},
|
||||
{
|
||||
"box": [[400, 900], [400, 200], [430, 200], [430, 900]],
|
||||
"upright": [100, 400, 800, 430],
|
||||
"text": "单位:元",
|
||||
"score": 1.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
ocr.add_sentences("doc", page, res, (0.0, 0.0), S, full_page=True)
|
||||
assert [x.text for x in page.sentences] == ["资产负债表", "单位:元"]
|
||||
x0, t, x1, b = page.sentences[0].quads[0]
|
||||
assert (x0, x1) == (round(300 / S, 2), round(330 / S, 2)) and b > t
|
||||
|
||||
|
||||
def test_ocr_findings_are_softened() -> None:
|
||||
from bid_doctor.check import findings
|
||||
|
||||
ocr_sent = Sentence(key="d:p1:s9", page_no=1, seq=9, text="壹拾万元", quads=[[0, 0, 1, 1]], source="ocr", conf=0.8)
|
||||
assert findings.ocr_based([(None, ocr_sent)])
|
||||
assert findings.soften("fatal", "大小写不一致。", numeric=True)[0] == "hint"
|
||||
sev, text = findings.soften("fatal", "负偏离未说明。", numeric=False)
|
||||
assert sev == "important" and text.endswith(findings.OCR_NOTE)
|
||||
|
||||
|
||||
def test_rules_skip_ocr_text_on_scanned_attachments() -> None:
|
||||
"""规则只查投标人自己填的格式文件:扫描附件、插图上的识别文字不查;整份扫描的投标文件照查。"""
|
||||
from uuid import uuid4
|
||||
|
||||
from bid_doctor.check import match, runner
|
||||
|
||||
text = Sentence(key="d:p1:s1", page_no=1, seq=1, text="投标函", quads=[[0, 0, 1, 1]])
|
||||
scan = Sentence(key="d:p9:s1", page_no=9, seq=1, text="试用期至___年", quads=[[0, 0, 1, 1]], source="ocr", conf=1.0)
|
||||
doc = match.BidDoc(uuid4(), 1, "投标文件", [text, scan], [9], [], [9])
|
||||
(kept,) = runner._rule_docs([doc], [{"id": str(doc.id), "scan_only": False}]) # pyright: ignore[reportPrivateUsage]
|
||||
assert [x.text for x in kept.sentences] == ["投标函"] and len(doc.sentences) == 2
|
||||
(whole,) = runner._rule_docs([doc], [{"id": str(doc.id), "scan_only": True}]) # pyright: ignore[reportPrivateUsage]
|
||||
assert len(whole.sentences) == 2
|
||||
270
server/tests/test_qualification_scan.py
Normal file
270
server/tests/test_qualification_scan.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""资质库上传识别(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
|
||||
Reference in New Issue
Block a user