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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user