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