计划见 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>
260 lines
12 KiB
Python
260 lines
12 KiB
Python
"""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
|