diff --git a/src/polygateway/transports/monkey_ocr.py b/src/polygateway/transports/monkey_ocr.py index 9af9edc..89faae6 100644 --- a/src/polygateway/transports/monkey_ocr.py +++ b/src/polygateway/transports/monkey_ocr.py @@ -28,8 +28,13 @@ from polygateway.transports._http_errors import ( response_body, summarize_body, ) -from polygateway.transports._monkey_document import _finite_number, _parse_page_size +from polygateway.transports._monkey_document import ( + _finite_number, + _parse_page_size, + parse_document_package, +) from polygateway.types import ( + OcrDocumentTransportResult, OcrLayoutElement, OcrLayoutTransportResult, OcrTextTransportResult, @@ -70,6 +75,11 @@ _DEFAULT_REJECTED_MARKERS: tuple[str, ...] = ( # 服务把一切异常(含它自己抛的 400、`Model not initialized` 这类真故障)都包成同一前缀, # 所以前缀只是必要条件,必须同时命中具体标记 _PARSING_FAILED_PREFIX = "parsing failed:" + +_MAX_PACKAGE_BYTES = 256 * 1024 * 1024 +"""整包(含 `images/*.jpg`)体积上限,仅文档路径。4 页含 4 图约 598KB,数百页含图的文档 +也远低于此;超限即拒,防止畸形/恶意响应把内存吃满。httpx 非流式 `get` 会先整包 `aread()`, +这正是文档路径改用流式下载并逐块累计的原因。""" # Adobe 阅读器容忍头部前有垃圾字节(惯例而非 ISO 32000 正文);服务端实测可解析 # 前缀 200 字节垃圾的 PDF(#34 设计 E3) _PDF_HEADER_WINDOW = 1024 @@ -244,6 +254,24 @@ def _parse_middle_json( raise ResultInvalidError(f"OCR 结果包内容无效: {exc}") from exc +def _package_too_large(source: SourceConfig, size: int) -> ResultInvalidError: + return ResultInvalidError( + f"{source.name} OCR 结果包体积超过上限 {_MAX_PACKAGE_BYTES}(已读 {size})", + source_name=source.name, + operation="download_result", + ) + + +def _check_declared_length(resp: httpx.Response, source: SourceConfig) -> None: + """声明长度超限就不必读了;缺失/非法的头部不作判据(靠累计字节兜底)。""" + try: + declared = int(resp.headers.get("content-length", "")) + except ValueError: + return + if declared > _MAX_PACKAGE_BYTES: + raise _package_too_large(source, declared) + + class MonkeyOcrTransport: """MonkeyOCR transport: 每源一个预配 httpx client,懒创建,aclose 统一释放。""" @@ -311,32 +339,47 @@ class MonkeyOcrTransport: text=content, raw={k: v for k, v in data.items() if k != "content"} ) - async def parse_layout( - self, *, image: bytes, source: SourceConfig, call_id: str - ) -> OcrLayoutTransportResult: - """两段协议: POST /parse → GET download_url → ZIP → 全元素提取。""" - del call_id - client = self._client_for(source) - with _translate_http_errors(source.name, "parse"): - resp = await client.post("/parse", files=self._files(image)) + async def _post_parse( + self, + client: httpx.AsyncClient, + data: bytes, + source: SourceConfig, + rejected_markers: tuple[str, ...], + ) -> str: + """两段协议的第一段: POST /parse → 校验 `success` → 返回 download_url。 + + layout 与 document 两条路径共用,保证"success 为假 → 附 status_code=200"等 + 设计 §10.2 的有意修复只有一份。 + """ + with _translate_http_errors(source.name, "parse", rejected_markers=rejected_markers): + resp = await client.post("/parse", files=self._files(data)) resp.raise_for_status() - data = self._response_json(resp, source, "parse") - if data.get("success") is not True: + body = self._response_json(resp, source, "parse") + if body.get("success") is not True: # 设计 §10.2 有意修复: 200 响应确证服务活着,附 status_code # 让治理侧按"响应即健康"记熔断成功(CHS 原样会误走本地拒绝分支) raise RequestRejectedError( - f"{source.name} OCR 解析失败: {data.get('message')}", + f"{source.name} OCR 解析失败: {body.get('message')}", source_name=source.name, status_code=resp.status_code, operation="parse", ) - download_url = data.get("download_url") + download_url = body.get("download_url") if not isinstance(download_url, str) or not download_url.strip(): raise TransientError( f"{source.name} /parse 响应缺少 download_url", source_name=source.name, operation="parse", ) + return download_url + + async def parse_layout( + self, *, image: bytes, source: SourceConfig, call_id: str + ) -> OcrLayoutTransportResult: + """两段协议: POST /parse → GET download_url → ZIP → 全元素提取。""" + del call_id + client = self._client_for(source) + download_url = await self._post_parse(client, image, source, ()) with _translate_http_errors(source.name, "download_result"): # 实测 download_url 为相对路径(/static/*.zip),相对 base_url 解析; # httpx 对绝对 URL 会忽略 base_url,两种形态天然兼容 @@ -349,6 +392,54 @@ class MonkeyOcrTransport: raw={"success": True, "download_url": download_url}, ) + async def parse_document( + self, *, document: bytes, source: SourceConfig, call_id: str + ) -> OcrDocumentTransportResult: + """整份文档: 本地预检 → POST /parse → 流式 GET → 线程内解析(#34 设计 §5)。""" + del call_id + if _sniff_media(document) == "unknown": + # 服务从未被打扰: HTML 付费墙页面、乱码、空串在批处理语料里最常见。不带 + # status_code(服务没响应),治理侧只归还探针、不计熔断成败 + raise RequestRejectedError( + f"{source.name} OCR 无法识别文档格式(既非 PDF 也非已知图片),拒绝发送", + source_name=source.name, + operation="parse", + ) + client = self._client_for(source) + download_url = await self._post_parse(client, document, source, self._rejected_markers) + package = await self._download_package(client, download_url, source) + # 纯函数、可能处理大对象: 放线程里,取消与 call_deadline_s 才能及时生效 + elements, page_sizes, markdown = await asyncio.to_thread(parse_document_package, package) + return OcrDocumentTransportResult( + elements=elements, + page_sizes=page_sizes, + markdown=markdown, + raw={"success": True, "download_url": download_url}, + ) + + async def _download_package( + self, client: httpx.AsyncClient, download_url: str, source: SourceConfig + ) -> bytes: + """流式下载结果包并限制体积。 + + **先处理错误状态,再检查体积**: 结果包的资源检查不得改变 HTTP 错误分类 + (一个声明超限的 401 仍是 SourceDead,响应体摘要也要保留——issue #10)。 + """ + chunks: list[bytes] = [] + total = 0 + with _translate_http_errors(source.name, "download_result"): + async with client.stream("GET", download_url) as resp: + if resp.is_error: + await resp.aread() # 先落缓冲:摘要口径只认已缓冲的响应体 + resp.raise_for_status() + _check_declared_length(resp, source) + async for chunk in resp.aiter_bytes(): + total += len(chunk) + if total > _MAX_PACKAGE_BYTES: + raise _package_too_large(source, total) + chunks.append(chunk) + return b"".join(chunks) + async def check_health(self, *, source: SourceConfig) -> bool: """GET /health 探测: 2xx → True,失败 → False,取消穿透(R10)。""" client = self._client_for(source) diff --git a/tests/unit/_document_fixtures.py b/tests/unit/_document_fixtures.py new file mode 100644 index 0000000..e3f017a --- /dev/null +++ b/tests/unit/_document_fixtures.py @@ -0,0 +1,71 @@ +"""OCR 文档结果包的合成 fixtures(#34)。 + +全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 块类型、table/image 的嵌套 +`blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位——第三方论文内容许可不明、 +医疗数据不入库(零业务假设 + P5),真实 ZIP 不提交。 +""" + +import io +import json +import zipfile + +_BBOX = (10.0, 20.0, 110.0, 40.0) + + +def _span(content="SYNTH-TEXT", type_="text"): + return {"type": type_, "content": content, "bbox": list(_BBOX), "score": 1.0} + + +def _text_block(content="SYNTH-TEXT", *, type_="text", bbox=_BBOX): + return { + "type": type_, + "bbox": list(bbox), + "index": 0, + "lines": [{"bbox": list(bbox), "spans": [_span(content)]}], + } + + +def _body(type_, spans, bbox=_BBOX): + return {"type": type_, "bbox": list(bbox), "lines": [{"bbox": list(bbox), "spans": spans}]} + + +def _table_block(html="
SYNTH-CELL
", extra_subs=()): + body = _body("table_body", [{"type": "table", "html": html, "image_path": "synth.jpg"}]) + return {"type": "table", "bbox": list(_BBOX), "index": 1, "blocks": [*extra_subs, body]} + + +def _image_block(): + body = _body("image_body", [{"type": "image", "image_path": "synth.jpg"}]) + return {"type": "image", "bbox": list(_BBOX), "index": 2, "blocks": [body]} + + +def _page(blocks=None, *, page_idx=0, page_size=(612.0, 792.0), **extra): + page = {"page_idx": page_idx, "page_size": list(page_size), "discarded_blocks": []} + if blocks is not None: + page["para_blocks"] = blocks + page.update(extra) + return page + + +def _package( + pages=None, + *, + md="# synth\n", + middle=None, + middle_name="doc_middle.json", + md_name=None, + extra=(), +): + """组装结果包。`middle` 给原始 bytes 时原样写入;`md_name` 默认由 middle 名配对。""" + if middle is None: + middle = json.dumps({"pdf_info": pages if pages is not None else [_page([])]}).encode() + if md_name is None: + md_name = middle_name[: -len("_middle.json")] + ".md" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr(middle_name, middle) + if md is not None: + zf.writestr(md_name, md if isinstance(md, bytes) else md.encode()) + for name, data in extra: + zf.writestr(name, data) + return buf.getvalue() diff --git a/tests/unit/test_monkey_document.py b/tests/unit/test_monkey_document.py index f2cc4cc..ce77c84 100644 --- a/tests/unit/test_monkey_document.py +++ b/tests/unit/test_monkey_document.py @@ -20,67 +20,16 @@ from polygateway.errors import ResultInvalidError from polygateway.transports import _monkey_document from polygateway.transports._monkey_document import parse_document_package from polygateway.types import OcrDocumentElement - -_BBOX = (10.0, 20.0, 110.0, 40.0) - - -def _span(content="SYNTH-TEXT", type_="text"): - return {"type": type_, "content": content, "bbox": list(_BBOX), "score": 1.0} - - -def _text_block(content="SYNTH-TEXT", *, type_="text", bbox=_BBOX): - return { - "type": type_, - "bbox": list(bbox), - "index": 0, - "lines": [{"bbox": list(bbox), "spans": [_span(content)]}], - } - - -def _body(type_, spans, bbox=_BBOX): - return {"type": type_, "bbox": list(bbox), "lines": [{"bbox": list(bbox), "spans": spans}]} - - -def _table_block(html="
SYNTH-CELL
", extra_subs=()): - body = _body("table_body", [{"type": "table", "html": html, "image_path": "synth.jpg"}]) - return {"type": "table", "bbox": list(_BBOX), "index": 1, "blocks": [*extra_subs, body]} - - -def _image_block(): - body = _body("image_body", [{"type": "image", "image_path": "synth.jpg"}]) - return {"type": "image", "bbox": list(_BBOX), "index": 2, "blocks": [body]} - - -def _page(blocks=None, *, page_idx=0, page_size=(612.0, 792.0), **extra): - page = {"page_idx": page_idx, "page_size": list(page_size), "discarded_blocks": []} - if blocks is not None: - page["para_blocks"] = blocks - page.update(extra) - return page - - -def _package( - pages=None, - *, - md="# synth\n", - middle=None, - middle_name="doc_middle.json", - md_name=None, - extra=(), -): - """组装结果包。`middle` 给原始 bytes 时原样写入;`md_name` 默认由 middle 名配对。""" - if middle is None: - middle = json.dumps({"pdf_info": pages if pages is not None else [_page([])]}).encode() - if md_name is None: - md_name = middle_name[: -len("_middle.json")] + ".md" - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - zf.writestr(middle_name, middle) - if md is not None: - zf.writestr(md_name, md if isinstance(md, bytes) else md.encode()) - for name, data in extra: - zf.writestr(name, data) - return buf.getvalue() +from tests.unit._document_fixtures import ( + _BBOX, + _body, + _image_block, + _package, + _page, + _span, + _table_block, + _text_block, +) def _parse(pages=None, **kwargs): diff --git a/tests/unit/test_monkey_ocr.py b/tests/unit/test_monkey_ocr.py index 31eeff8..47fca66 100644 --- a/tests/unit/test_monkey_ocr.py +++ b/tests/unit/test_monkey_ocr.py @@ -8,6 +8,7 @@ P5 医疗数据不入库)。 import asyncio import io import json +import threading import zipfile import httpx @@ -19,6 +20,7 @@ from polygateway.errors import ( SourceDeadError, TransientError, ) +from polygateway.transports import monkey_ocr from polygateway.transports.monkey_ocr import ( _DEFAULT_REJECTED_MARKERS, MonkeyOcrTransport, @@ -28,7 +30,10 @@ from polygateway.transports.monkey_ocr import ( _translate_http_errors, _upload_part, ) -from polygateway.types import SourceConfig +from polygateway.types import OcrDocumentElement, OcrDocumentTransportResult, SourceConfig +from tests.unit._document_fixtures import _package as _doc_package +from tests.unit._document_fixtures import _page as _doc_page +from tests.unit._document_fixtures import _table_block, _text_block def _source(**overrides): @@ -535,8 +540,9 @@ class TestRejectedMarkers: assert type(err) is exc_type def test_translate_http_errors_passes_markers_through(self): - with pytest.raises(RequestRejectedError), _translate_http_errors( - "monkey_1", "parse", rejected_markers=_DEFAULT_REJECTED_MARKERS + with ( + pytest.raises(RequestRejectedError), + _translate_http_errors("monkey_1", "parse", rejected_markers=_DEFAULT_REJECTED_MARKERS), ): raise _http_error(500, _parsing_failed(_DEFAULT_REJECTED_MARKERS[0])) # 默认不传: 行为不变 @@ -563,6 +569,198 @@ class TestRejectedMarkers: await t.parse_layout(image=b"jpg", source=_source(), call_id="c1") +class TestParseDocument: + """#34 设计 §5: 本地预检 → POST /parse → 流式 GET(体积上限)→ 线程内解析。""" + + @staticmethod + def _doc_zip(): + return _doc_package( + [_doc_page([_text_block("SYNTH-A"), _table_block("SYNTH
")])] + ) + + def _transport(self, seen=None, *, zip_resp=None, parse_resp=None): + def handler(request: httpx.Request) -> httpx.Response: + if seen is not None: + seen.append((request.method, request.url.path, request.read())) + if request.url.path == "/parse": + return parse_resp or httpx.Response(200, json=_parse_body()) + if request.url.path.startswith("/static/"): + return zip_resp or httpx.Response(200, content=self._doc_zip()) + return httpx.Response(500) + + return _transport_for(handler) + + async def test_full_chain_relative_download_url(self): + t = self._transport() + result = await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + assert isinstance(result, OcrDocumentTransportResult) + assert [(e.type, e.text) for e in result.elements] == [ + ("text", "SYNTH-A"), + ("table", "SYNTH
"), + ] + assert all(isinstance(e, OcrDocumentElement) for e in result.elements) + assert result.page_sizes == [(612.0, 792.0)] + assert result.markdown == "# synth\n" + assert result.raw == {"success": True, "download_url": "/static/sample_parsed_1.zip"} + + async def test_absolute_download_url(self): + url = "http://10.77.0.20:7866/static/abs.zip" + t = self._transport(parse_resp=httpx.Response(200, json=_parse_body(url))) + result = await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + assert result.raw["download_url"] == url + + @pytest.mark.parametrize( + ("document", "filename", "content_type"), + [ + pytest.param(_PDF_BODY, b"doc.pdf", b"application/pdf", id="pdf"), + pytest.param(_JUNK + _PDF_BODY, b"doc.pdf", b"application/pdf", id="pdf-junk-prefix"), + pytest.param(b"\xff\xd8\xff\xe0abc", b"image.jpg", b"image/jpeg", id="jpeg"), + ], + ) + async def test_upload_label_matrix_for_the_document_entry( + self, document, filename, content_type + ): + seen = [] + await self._transport(seen).parse_document( + document=document, source=_source(), call_id="c1" + ) + post_body = next(body for method, path, body in seen if path == "/parse") + assert b'filename="' + filename + b'"' in post_body + assert b"Content-Type: " + content_type in post_body + + @pytest.mark.parametrize( + "document", + [b"paywall", b"GARBAGE-NOT-AN-IMAGE", b""], + ids=["html", "garbage", "empty"], + ) + async def test_unrecognizable_bytes_are_rejected_locally_without_any_request(self, document): + seen = [] + with pytest.raises(RequestRejectedError) as ei: + await self._transport(seen).parse_document( + document=document, source=_source(), call_id="c1" + ) + assert seen == [] # 服务从未被打扰 + assert ei.value.status_code is None # 服务未响应: 熔断侧只归还探针,不计成败 + + @pytest.mark.parametrize("marker", _DEFAULT_REJECTED_MARKERS) + async def test_known_deterministic_post_failures_are_rejected_with_status_500(self, marker): + body = _parsing_failed(marker).encode() + t = self._transport(parse_resp=httpx.Response(500, content=body)) + with pytest.raises(RequestRejectedError) as ei: + await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + assert ei.value.status_code == 500 and ei.value.body_text + + @pytest.mark.parametrize( + "reason", + ["CUDA out of memory", "400: Unsupported file type: .webp", "500: Model not initialized"], + ) + async def test_unknown_post_500_stays_transient(self, reason): + t = self._transport( + parse_resp=httpx.Response(500, content=_parsing_failed(reason).encode()) + ) + with pytest.raises(TransientError): + await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + + async def test_success_false_is_rejected_with_status_200(self): + t = self._transport( + parse_resp=httpx.Response(200, json={"success": False, "message": "no"}) + ) + with pytest.raises(RequestRejectedError) as ei: + await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + assert ei.value.status_code == 200 + + async def test_missing_download_url_and_non_json_are_transient(self): + body = {"success": True, "message": "ok"} + for resp in (httpx.Response(200, json=body), httpx.Response(200, content=b"not json")): + t = self._transport(parse_resp=resp) + with pytest.raises(TransientError): + await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + + @pytest.mark.parametrize( + ("status", "exc_type"), + [ + (401, SourceDeadError), + (429, TransientError), + (500, TransientError), + (404, RequestRejectedError), + ], + ) + async def test_download_errors_keep_their_class_and_body(self, status, exc_type): + """GET 阶段不适用确定性窄化,且响应体摘要随每个分支(issue #10)。""" + body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]) + t = self._transport(zip_resp=httpx.Response(status, content=body.encode())) + with pytest.raises(exc_type) as ei: + await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + assert ei.value.status_code == status and ei.value.body_text == body + + async def test_error_status_with_oversized_content_length_is_still_classified_by_http( + self, monkeypatch + ): + """结果包资源检查不得改变错误分类: 超限的 Content-Length 出现在 401 上,仍是 SourceDead。""" + monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 100) + resp = httpx.Response(401, content=b"denied", headers={"content-length": "101"}) + with pytest.raises(SourceDeadError): + await self._transport(zip_resp=resp).parse_document( + document=_PDF_BODY, source=_source(), call_id="c1" + ) + + async def test_oversized_declared_content_length_is_rejected(self, monkeypatch): + monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 100) + resp = httpx.Response(200, content=b"x" * 10, headers={"content-length": "101"}) + with pytest.raises(ResultInvalidError): + await self._transport(zip_resp=resp).parse_document( + document=_PDF_BODY, source=_source(), call_id="c1" + ) + + async def test_oversized_streamed_body_is_rejected_by_cumulative_count(self, monkeypatch): + """没有 Content-Length(分块传输)时靠累计字节兜底: 多个小块合起来超限。""" + monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 100) + + async def chunks(): + for _ in range(3): + yield b"x" * 60 + + resp = httpx.Response(200, content=chunks()) + with pytest.raises(ResultInvalidError): + await self._transport(zip_resp=resp).parse_document( + document=_PDF_BODY, source=_source(), call_id="c1" + ) + + async def test_bad_package_is_result_invalid(self): + t = self._transport(zip_resp=httpx.Response(200, content=b"not a zip")) + with pytest.raises(ResultInvalidError): + await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + + async def test_cancellation_during_parse_does_not_block_the_event_loop(self, monkeypatch): + """解析在线程里跑: 同步解析大对象会占住事件循环,取消与 call_deadline_s 都会失效。 + + 线程内用 threading.Event 阻塞,并用 call_soon_threadsafe 通知(线程里不能直接用 + asyncio.Event)。若实现退回同步解析,桩会把循环占满 1 秒后正常返回, + `task.cancel()` 就取不到 CancelledError——用例转红。 + """ + loop = asyncio.get_running_loop() + entered = asyncio.Event() + release = threading.Event() + + def blocking_parse(zip_bytes): + loop.call_soon_threadsafe(entered.set) + release.wait(timeout=1) + return [], [(1.0, 1.0)], "" + + monkeypatch.setattr(monkey_ocr, "parse_document_package", blocking_parse) + task = asyncio.create_task( + self._transport().parse_document(document=_PDF_BODY, source=_source(), call_id="c1") + ) + try: + await asyncio.wait_for(entered.wait(), timeout=2) + await asyncio.sleep(0.01) # 线程还卡着,循环必须仍能调度别的协程 + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + release.set() # 让线程收尾,不泄漏 + + class TestCheckHealth: async def test_healthy(self): t = _transport_for(_routes(health_resp=httpx.Response(200, json={"status": "healthy"})))