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:
YANG JIANKUAN
2026-11-05 17:50:13 +08:00
parent 0b783ababa
commit 3344f09563
63 changed files with 4973 additions and 345 deletions

259
server/tests/test_ocr.py Normal file
View 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