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:
2026-10-21 23:50:51 -04:00
parent 0357d5afb3
commit 61cace71c4
4 changed files with 386 additions and 77 deletions
+104 -13
View File
@@ -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)