feat: add parse_document to the MonkeyOCR transport
Unrecognizable bytes are rejected before any request. The result package is downloaded as a stream with a cumulative size cap, and error statuses are classified before the size check. Parsing runs in a worker thread so cancellation and call deadlines stay effective. parse_layout shares the first protocol stage without behavior change.
This commit is contained in:
@@ -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="<table><tr><td>SYNTH-CELL</td></tr></table>", 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()
|
||||
@@ -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="<table><tr><td>SYNTH-CELL</td></tr></table>", 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):
|
||||
|
||||
@@ -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("<table>SYNTH</table>")])]
|
||||
)
|
||||
|
||||
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", "<table>SYNTH</table>"),
|
||||
]
|
||||
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"<html>paywall</html>", 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"})))
|
||||
|
||||
Reference in New Issue
Block a user