Files
bid-doctor/server/tests/test_ocr.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

260 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.

"""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