fix: close adversarial review findings on document parsing

Malformed download URLs are rejected before the second request, error bodies are read only up to a bound, the telemetry summary keeps structural dispatch for duck-typed results, and the BMP magic now requires a valid DIB header. Docs and design are aligned with the implementation, and new test wording is neutral.
This commit is contained in:
2026-10-22 02:09:06 -04:00
parent bb621cad4d
commit cb1e08f1f3
10 changed files with 180 additions and 25 deletions
+5 -1
View File
@@ -20,7 +20,11 @@
- **`parse_layout(PDF)` 的上传分支改变**(图片数据集 → PDF 数据集)。差分实测: 文本型与无文字层的位图型 2 页 PDF,`_middle.json` / `_content_list.json` / `.md` **逐字相同**;**旋转页、带批注页未测,不构成保证**。该路径无已知消费者(医院线只传图片)。 - **`parse_layout(PDF)` 的上传分支改变**(图片数据集 → PDF 数据集)。差分实测: 文本型与无文字层的位图型 2 页 PDF,`_middle.json` / `_content_list.json` / `.md` **逐字相同**;**旋转页、带批注页未测,不构成保证**。该路径无已知消费者(医院线只传图片)。
- **`parse_document` 对坏输入的处置**(仅新路径;共享分类不变): 无法识别的字节本地拒绝、不发请求(`RequestRejectedError`,不带 `status_code`);服务把一切失败包成 `500 "Parsing failed: …"`,其中已知的确定性坏输入(`Failed to open stream` / `Cannot open empty stream` / `cannot save with zero pages` / `is no PDF`)归 `RequestRejectedError`(`status_code=500`)——**不重试、熔断记成功不计窗口**。其余 500 仍是瞬时错误。标记集合可经 `MonkeyOcrTransport(deterministic_failure_markers=...)` 覆盖。 - **`parse_document` 对坏输入的处置**(仅新路径;共享分类不变): 无法识别的字节本地拒绝、不发请求(`RequestRejectedError`,不带 `status_code`);服务把一切失败包成 `500 "Parsing failed: …"`,其中已知的确定性坏输入(`Failed to open stream` / `Cannot open empty stream` / `cannot save with zero pages` / `is no PDF`)归 `RequestRejectedError`(`status_code=500`)——**不重试、熔断记成功不计窗口**。其余 500 仍是瞬时错误。标记集合可经 `MonkeyOcrTransport(deterministic_failure_markers=...)` 覆盖。
- 结果包解析的外部数据异常边界统一落 `ResultInvalidError`(含加密成员、未知压缩方法、巨大整数、深嵌套 JSON),不漏出原生异常——漏出会让客户端既不写终态遥测行、又不归还半开探针。 - 结果包解析的外部数据异常边界统一落 `ResultInvalidError`(含加密成员、未知压缩方法、巨大整数、深嵌套 JSON),不漏出原生异常——漏出会让客户端既不写终态遥测行、又不归还半开探针。
- 文档路径流式下载并限制整包 256 MiB;`_middle.json` ≤ 64 MiB、`.md` ≤ 16 MiB;解析在线程里执行,取消与 `call_deadline_s` 不被同步解析占住的事件循环拖住。`parse_layout` 的口径(bbox 整数化退化校验、`page_idx` 回退、无体积上限)**保持原样**。 - 文档路径流式下载并限制整包 256 MiB;`_middle.json` ≤ 64 MiB、`.md` ≤ 16 MiB;解析在线程里执行,取消与 `call_deadline_s` **基本不被占住**(`json.loads` 持有 GIL:实测 54 MiB 的 middle 解析约 1 秒、事件循环最大停顿约 0.3 秒;取消后线程自然收尾、占用默认 executor,正常大小是毫秒级)。`parse_layout` 的口径(bbox 整数化退化校验、`page_idx` 回退、无体积上限)**保持原样**。
- **共用的第一段协议新增 `download_url` 校验**: 服务响应里的畸形 `download_url`(控制字符、坏方括号、越界端口)此前会让 `httpx.InvalidURL` 等原生异常漏出 `OcrClient`(不写终态遥测行、半开探针不归还),现归瞬时错误、不发第二段请求。`parse_layout` 共用这一处,属顺带修复;**不限制主机**(基线接受绝对地址)。
- **嗅探副作用**: 前 1KB 内含 `%PDF-` 字样的非 PDF 文本在 `recognize_text` / `parse_layout` 上的上传标签由 `image.jpg` 变为 `doc.pdf`;BMP 魔数现要求 DIB 头大小字段合法(不再单凭 `BM` 两字节)。
- 文档路径读取错误响应体只读前 64 KiB(摘要只需要几 KiB),不再把巨大的错误体整个读进内存。
### 已知限制与运维须知(详见 README「1.3.11 OCR 整份文档解析」) ### 已知限制与运维须知(详见 README「1.3.11 OCR 整份文档解析」)
+2 -2
View File
@@ -741,7 +741,7 @@ doc.markdown # 服务生成的整篇 Markdown
| 变更 | 说明 | | 变更 | 说明 |
| --- | --- | | --- | --- |
| PDF 上传标签 | MonkeyOCR 按**上传文件名的扩展名**分派。此前所有上传都标 `image.jpg`;现在以 `%PDF-` 开头(容忍前 1KB 内的垃圾前缀)的字节改标 `doc.pdf`,**其余一律沿用 `image.jpg`**。三个方法共用这一个判定 | | PDF 上传标签 | MonkeyOCR 按**上传文件名的扩展名**分派。此前所有上传都标 `image.jpg`;现在**前 1KB 内含 `%PDF-` 且起始不是已知图片魔数**的字节改标 `doc.pdf`(容忍垃圾前缀;副作用是前 1KB 里恰好出现 `%PDF-` 字样的非 PDF 文本也会被改标),**其余一律沿用 `image.jpg`**。三个方法共用这一个判定 |
| `recognize_text(PDF)` | **修复静默错误**: PDF 标成 `image.jpg` 时服务返回 `success:true` 与幻觉文本(实测 `"The image contains a single character: 1"`);现在返回整份全文(无页码,请改用 `parse_document`) | | `recognize_text(PDF)` | **修复静默错误**: PDF 标成 `image.jpg` 时服务返回 `success:true` 与幻觉文本(实测 `"The image contains a single character: 1"`);现在返回整份全文(无页码,请改用 `parse_document`) |
| `parse_layout(PDF)` | 上传分支由图片数据集改为 PDF 数据集。已测的两种 2 页 PDF(文本型、无文字层的位图型)结果与此前**逐字相同**;**旋转页、带批注页未测**,不构成保证。医院线只传图片,不受影响 | | `parse_layout(PDF)` | 上传分支由图片数据集改为 PDF 数据集。已测的两种 2 页 PDF(文本型、无文字层的位图型)结果与此前**逐字相同**;**旋转页、带批注页未测**,不构成保证。医院线只传图片,不受影响 |
| 坏输入(仅 `parse_document`) | 无法识别的字节(HTML 页面、乱码、空串)本地拒绝、不发请求;服务对截断/伪造 PDF 返回的已知确定性 500(`Failed to open stream` 等)归 `RequestRejectedError`(`status_code=500`),**不重试、不计熔断**。其余 500 仍是瞬时错误 | | 坏输入(仅 `parse_document`) | 无法识别的字节(HTML 页面、乱码、空串)本地拒绝、不发请求;服务对截断/伪造 PDF 返回的已知确定性 500(`Failed to open stream` 等)归 `RequestRejectedError`(`status_code=500`),**不重试、不计熔断**。其余 500 仍是瞬时错误 |
@@ -778,7 +778,7 @@ OCR_DOC__CALL_DEADLINE_S=900 # 单份文档的硬上限;须 ≤ PGW
- **服务端丢弃脚注、页眉页脚、页码**(版面模型把它们标为丢弃类,span 内容为空,未被 OCR):论文表格脚注里的显著性与缩写说明不会出现在 `elements` 或 `markdown` 里,库无从补偿。 - **服务端丢弃脚注、页眉页脚、页码**(版面模型把它们标为丢弃类,span 内容为空,未被 OCR):论文表格脚注里的显著性与缩写说明不会出现在 `elements` 或 `markdown` 里,库无从补偿。
- 多行 / 多 span 的文本块、`inline_equation` span、嵌套 caption / footnote 子块这三条路径**没有真实样本**(实测样本全是 1 行 1 span,默认版面模型不产生嵌套子块),只由构造 fixture 测试。 - 多行 / 多 span 的文本块、`inline_equation` span、嵌套 caption / footnote 子块这三条路径**没有真实样本**(实测样本全是 1 行 1 span,默认版面模型不产生嵌套子块),只由构造 fixture 测试。
- **内存**: 文档路径整包上限 256 MiB(流式累计),合并分块与解析阶段会让瞬时峰值接近 2 倍上限;并发 2 时请按此预留内存。 - **内存**: 文档路径整包上限 256 MiB(流式累计),合并分块会让瞬时峰值接近 2 倍上限;**字节上限不等于内存上限**——JSON 解析后的对象体积可达原文数十倍(实测 11 MiB 的空数组 JSON 解析后约 270 MiB),异常大的包请按此预留内存。真实样本约 72 KB/页,正常大小是毫秒级;`_middle.json` 的 64 MiB 上限与整包 256 MiB 上限都只界定字节数,不界定成员条数。
- `parse_document` 仅接受 PDF 与魔数表内的图片(JPEG / PNG / TIFF / BMP / GIF / WebP);表外的可解码图片格式请走 `parse_layout`。 - `parse_document` 仅接受 PDF 与魔数表内的图片(JPEG / PNG / TIFF / BMP / GIF / WebP);表外的可解码图片格式请走 `parse_layout`。
- `parse_layout` 的校验口径保持原样(bbox 整数化退化拒绝、`page_idx` 非法回退枚举下标、无体积上限);`parse_document` 更严的有零页拒绝与 `page_idx` 必须等于页序,更宽的是 bbox 只要求有限且有序。 - `parse_layout` 的校验口径保持原样(bbox 整数化退化拒绝、`page_idx` 非法回退枚举下标、无体积上限);`parse_document` 更严的有零页拒绝与 `page_idx` 必须等于页序,更宽的是 bbox 只要求有限且有序。
@@ -70,7 +70,8 @@ issue 把症状拆成两条;**两条同根**: 库把 OCR 输入建模成"一张
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 正常 PDF | `doc.pdf`,整份全文(E4 修复) | `doc.pdf`(上传分支变更,见 §8) | `doc.pdf`,完整文档结果 | | 正常 PDF | `doc.pdf`,整份全文(E4 修复) | `doc.pdf`(上传分支变更,见 §8) | `doc.pdf`,完整文档结果 |
| 前缀垃圾的 PDF | 同上 | 同上 | 同上 | | 前缀垃圾的 PDF | 同上 | 同上 | 同上 |
| 已知图片 / 其它可解码图片 | `image.jpg`(不变) | `image.jpg`(不变) | `image.jpg`,1 页结果 | | 魔数表内的图片(JPEG/PNG/TIFF/BMP/GIF/WebP) | `image.jpg`(不变) | `image.jpg`(不变) | `image.jpg`,1 页结果 |
| 魔数表之外的可解码图片 | `image.jpg`(不变,服务按内容解码) | `image.jpg`(不变) | **本地 `RequestRejectedError`**(请改走 `parse_layout`;表内格式的扩充另议) |
| 无法识别的字节(HTML 页面、乱码、空) | `image.jpg`(不变,服务给幻觉文本 E13) | `image.jpg`(不变,服务 500) | **本地 `RequestRejectedError`,不发 HTTP** | | 无法识别的字节(HTML 页面、乱码、空) | `image.jpg`(不变,服务给幻觉文本 E13) | `image.jpg`(不变,服务 500) | **本地 `RequestRejectedError`,不发 HTTP** |
### Q3 文字内容从哪里来 ### Q3 文字内容从哪里来
+7 -5
View File
@@ -102,7 +102,7 @@ class _AttemptOutcome:
class OcrClient: class OcrClient:
"""治理化 OCR 入口: 同时实现 OcrTextPort 与 OcrLayoutPort(D9 端口族)。 """治理化 OCR 入口: 同时实现 OcrTextPort、OcrLayoutPort 与 OcrDocumentPort(D9 端口族;文档入口见 #34)。
两端点打同一服务实例池,共享同一 scope 的限流/熔断/选源状态; 两端点打同一服务实例池,共享同一 scope 的限流/熔断/选源状态;
与其他 scope 共享后端实例即共享全局闸(显式传入,禁止隐式全局)。 与其他 scope 共享后端实例即共享全局闸(显式传入,禁止隐式全局)。
@@ -700,15 +700,17 @@ class OcrClient:
def _summarize(result: _TransportResult) -> str: def _summarize(result: _TransportResult) -> str:
"""遥测行 response 摘要。**永不写入文档正文**:文档只记规模(元素数/页数/md 字数)。 """遥测行 response 摘要。**永不写入文档正文**:文档只记规模(元素数/页数/md 字数)。
用 isinstance 显式区分三类结果:旧的 `hasattr(result, "text")` 对没有顶层 `text` 的 **按结构分派而不是 isinstance**: 旧实现就是 `hasattr(result, "text")`,下游测试替身
新结果会静默套成 layout 摘要,漏记 `md_chars`。 可能返回非库类型的鸭子对象,换成 isinstance 会让它们在开启遥测后以原生
AttributeError 漏出。顺序要紧:文档结果有 `markdown`,先于只看 `text` 的判定,否则
会被静默套成 layout 摘要而漏记 `md_chars`。
""" """
if isinstance(result, OcrDocumentTransportResult): if hasattr(result, "markdown"):
return ( return (
f"<document elements={len(result.elements)} pages={len(result.page_sizes)} " f"<document elements={len(result.elements)} pages={len(result.page_sizes)} "
f"md_chars={len(result.markdown)}>" f"md_chars={len(result.markdown)}>"
) )
if isinstance(result, OcrTextTransportResult): if hasattr(result, "text"):
return result.text[:_RESPONSE_TEXT_CAP] return result.text[:_RESPONSE_TEXT_CAP]
return f"<elements n={len(result.elements)} pages={len(result.page_sizes)}>" return f"<elements n={len(result.elements)} pages={len(result.page_sizes)}>"
+69 -6
View File
@@ -56,11 +56,12 @@ _IMAGE_MAGICS: tuple[bytes, ...] = (
b"\x89PNG\r\n\x1a\n", # PNG b"\x89PNG\r\n\x1a\n", # PNG
b"II*\x00", # TIFF 小端 b"II*\x00", # TIFF 小端
b"MM\x00*", # TIFF 大端 b"MM\x00*", # TIFF 大端
b"BM", # BMP
b"GIF87a", b"GIF87a",
b"GIF89a", b"GIF89a",
) )
_PDF_HEADER = b"%PDF-" _PDF_HEADER = b"%PDF-"
# BMP 的 "BM" 只有两字节,任何以 BM 开头的文本都会撞上。再要求偏移 14 处的 DIB 头大小字段合法
_BMP_DIB_HEADER_SIZES = frozenset({12, 40, 52, 56, 64, 108, 124})
# 文档路径把这些「确定性坏输入」的 500 改归 RequestRejected(#34 设计 §6)。小写子串匹配, # 文档路径把这些「确定性坏输入」的 500 改归 RequestRejected(#34 设计 §6)。小写子串匹配,
# 取 2026-10-21 实测(截断 PDF / 伪 PDF 头 / 乱码 / 空文件 / 扩展名与内容不符)。 # 取 2026-10-21 实测(截断 PDF / 伪 PDF 头 / 乱码 / 空文件 / 扩展名与内容不符)。
@@ -76,6 +77,10 @@ _DEFAULT_REJECTED_MARKERS: tuple[str, ...] = (
# 所以前缀只是必要条件,必须同时命中具体标记 # 所以前缀只是必要条件,必须同时命中具体标记
_PARSING_FAILED_PREFIX = "parsing failed:" _PARSING_FAILED_PREFIX = "parsing failed:"
_ERROR_BODY_READ_CAP = 64 * 1024
"""文档路径读取错误响应体的字节上限。摘要只取几 KiB(`summarize_body`),服务或中间人回一个
巨大的错误体不该被整个读进内存(独立审查实测 600 MiB 的 500 让 RSS 涨到 1.8 GiB)。"""
_MAX_PACKAGE_BYTES = 256 * 1024 * 1024 _MAX_PACKAGE_BYTES = 256 * 1024 * 1024
"""整包(含 `images/*.jpg`)体积上限,仅文档路径。4 页含 4 图约 598KB,数百页含图的文档 """整包(含 `images/*.jpg`)体积上限,仅文档路径。4 页含 4 图约 598KB,数百页含图的文档
也远低于此;超限即拒,防止畸形/恶意响应把内存吃满。httpx 非流式 `get` 会先整包 `aread()`, 也远低于此;超限即拒,防止畸形/恶意响应把内存吃满。httpx 非流式 `get` 会先整包 `aread()`,
@@ -85,6 +90,20 @@ _MAX_PACKAGE_BYTES = 256 * 1024 * 1024
_PDF_HEADER_WINDOW = 1024 _PDF_HEADER_WINDOW = 1024
def _is_webp(data: bytes) -> bool:
"""RIFF 容器且类型码为 WEBP。"""
return data[:4] == b"RIFF" and data[8:12] == b"WEBP"
def _is_bmp(data: bytes) -> bool:
"""`BM` 开头且 DIB 头大小字段合法(单靠两字节魔数会误判以 BM 开头的文本)。"""
return (
data.startswith(b"BM")
and len(data) >= 18
and int.from_bytes(data[14:18], "little") in _BMP_DIB_HEADER_SIZES
)
def _sniff_media(data: bytes) -> _MediaKind: def _sniff_media(data: bytes) -> _MediaKind:
"""按内容判定上传类型: 图片魔数 → 前 1KB 内 `%PDF-` → 未知。 """按内容判定上传类型: 图片魔数 → 前 1KB 内 `%PDF-` → 未知。
@@ -92,7 +111,7 @@ def _sniff_media(data: bytes) -> _MediaKind:
若与内容不符会得到 HTTP 500(`is no PDF`)或——在 `/ocr/text` 上——`success:true` 若与内容不符会得到 HTTP 500(`is no PDF`)或——在 `/ocr/text` 上——`success:true`
的幻觉文本(#34 设计 E2/E4)。所以标签只能由库按内容定,不开放给调用方声明。 的幻觉文本(#34 设计 E2/E4)。所以标签只能由库按内容定,不开放给调用方声明。
""" """
if data.startswith(_IMAGE_MAGICS) or (data[:4] == b"RIFF" and data[8:12] == b"WEBP"): if data.startswith(_IMAGE_MAGICS) or _is_webp(data) or _is_bmp(data):
return "image" return "image"
if _PDF_HEADER in data[:_PDF_HEADER_WINDOW]: if _PDF_HEADER in data[:_PDF_HEADER_WINDOW]:
return "pdf" return "pdf"
@@ -254,6 +273,30 @@ def _parse_middle_json(
raise ResultInvalidError(f"OCR 结果包内容无效: {exc}") from exc raise ResultInvalidError(f"OCR 结果包内容无效: {exc}") from exc
def _check_download_url(download_url: str, source: SourceConfig) -> None:
"""`download_url` 来自服务端响应,属外部输入:畸形值先于第二段请求拒绝。
控制字符、坏方括号会让 httpx 抛 `InvalidURL`(不属 `HTTPError` 体系),越界端口会在连接阶段
抛 ExceptionGroup;二者都会从 `_translate_http_errors` 下漏出四分类,`OcrClient._call` 只接
`PolyGatewayError`,于是不写终态遥测行、持有的半开探针也不归还。服务返回畸形 URL 是协议故障,
与"响应缺少 download_url"同归瞬时错误。**不限制主机**(基线接受绝对地址,测试钉住)。
"""
try:
url = httpx.URL(download_url)
except httpx.InvalidURL as exc:
raise TransientError(
f"{source.name} /parse 响应的 download_url 非法: {exc}",
source_name=source.name,
operation="parse",
) from exc
if url.port is not None and not 0 < url.port <= 65535:
raise TransientError(
f"{source.name} /parse 响应的 download_url 端口越界: {url.port}",
source_name=source.name,
operation="parse",
)
def _package_too_large(source: SourceConfig, size: int) -> ResultInvalidError: def _package_too_large(source: SourceConfig, size: int) -> ResultInvalidError:
return ResultInvalidError( return ResultInvalidError(
f"{source.name} OCR 结果包体积超过上限 {_MAX_PACKAGE_BYTES}(已读 {size})", f"{source.name} OCR 结果包体积超过上限 {_MAX_PACKAGE_BYTES}(已读 {size})",
@@ -262,6 +305,22 @@ def _package_too_large(source: SourceConfig, size: int) -> ResultInvalidError:
) )
async def _bounded_error_response(resp: httpx.Response) -> httpx.Response:
"""读错误响应体的前 `_ERROR_BODY_READ_CAP` 字节,复制成已缓冲的响应。"""
data = bytearray()
async for chunk in resp.aiter_bytes():
data += chunk
if len(data) >= _ERROR_BODY_READ_CAP:
break
headers = {k: v for k, v in resp.headers.items() if k.lower() == "content-type"}
return httpx.Response(
resp.status_code,
headers=headers,
content=bytes(data[:_ERROR_BODY_READ_CAP]),
request=resp.request,
)
def _check_declared_length(resp: httpx.Response, source: SourceConfig) -> None: def _check_declared_length(resp: httpx.Response, source: SourceConfig) -> None:
"""声明长度超限就不必读了;缺失/非法的头部不作判据(靠累计字节兜底)。""" """声明长度超限就不必读了;缺失/非法的头部不作判据(靠累计字节兜底)。"""
try: try:
@@ -371,6 +430,7 @@ class MonkeyOcrTransport:
source_name=source.name, source_name=source.name,
operation="parse", operation="parse",
) )
_check_download_url(download_url, source)
return download_url return download_url
async def parse_layout( async def parse_layout(
@@ -430,10 +490,13 @@ class MonkeyOcrTransport:
with _translate_http_errors(source.name, "download_result"): with _translate_http_errors(source.name, "download_result"):
async with client.stream("GET", download_url) as resp: async with client.stream("GET", download_url) as resp:
if not resp.is_success: if not resp.is_success:
# 先落缓冲:摘要口径只认已缓冲的响应体。条件是「非 2xx」而不是 `is_error`: # 条件是「非 2xx」而不是 `is_error`:1xx/3xx 同样需要保留摘要。错误体只读
# `raise_for_status()` 对 1xx/3xx 同样会抛,漏掉它们会让摘要丢失 # 有界的前缀并复制成已缓冲的响应(摘要口径只认已缓冲的响应体)
await resp.aread() raise httpx.HTTPStatusError(
resp.raise_for_status() f"HTTP {resp.status_code}",
request=resp.request,
response=await _bounded_error_response(resp),
)
_check_declared_length(resp, source) _check_declared_length(resp, source)
async for chunk in resp.aiter_bytes(): async for chunk in resp.aiter_bytes():
total += len(chunk) total += len(chunk)
@@ -2,7 +2,7 @@
**成败取决于外部服务当下状态**,按 CLAUDE.md §4.6 一律标 `slow`: 默认套件不收集,发布清单 **成败取决于外部服务当下状态**,按 CLAUDE.md §4.6 一律标 `slow`: 默认套件不收集,发布清单
第 4 步按 diff 交集选跑(`pytest tests/integration/test_monkey_ocr_document_live.py -m slow`)。 第 4 步按 diff 交集选跑(`pytest tests/integration/test_monkey_ocr_document_live.py -m slow`)。
服务与医院线共用——所以每条用例只发**单份极小文档**、串行、不压测。 服务与其它业务线共用——所以每条用例只发**单份极小文档**、串行、不压测。
文档字节由本文件手写构造(最小合法 PDF,无第三方内容、无新依赖)。 文档字节由本文件手写构造(最小合法 PDF,无第三方内容、无新依赖)。
不依赖 `data/soak` 图片语料,故**不**并入带语料 `skipif` 的 `test_monkey_ocr_live.py`。 不依赖 `data/soak` 图片语料,故**不**并入带语料 `skipif` 的 `test_monkey_ocr_live.py`。
@@ -65,7 +65,7 @@ def _env(**extra: str) -> dict[str, str]:
"OCR__MONKEY__1__API_KEY": "none", "OCR__MONKEY__1__API_KEY": "none",
"OCR__MONKEY__1__MODEL": "monkey-ocr", "OCR__MONKEY__1__MODEL": "monkey-ocr",
"OCR__MONKEY__1__TIMEOUT_S": "300", "OCR__MONKEY__1__TIMEOUT_S": "300",
"OCR__MONKEY__1__MAX_CONCURRENCY": "1", # 与医院线共用,串行 "OCR__MONKEY__1__MAX_CONCURRENCY": "1", # 与其它业务线共用,串行
# 服务在 LAN,开发机的系统代理会对内网地址回 403 # 服务在 LAN,开发机的系统代理会对内网地址回 403
"OCR__MONKEY__1__TRUST_ENV": "false", "OCR__MONKEY__1__TRUST_ENV": "false",
} }
+2 -2
View File
@@ -1,8 +1,8 @@
"""OCR 文档结果包的合成 fixtures(#34)。 """OCR 文档结果包的合成 fixtures(#34)。
全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 块类型、table/image 的嵌套 全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 块类型、table/image 的嵌套
`blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位——第三方论文内容许可不明、 `blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位——第三方文档内容许可不明、
医疗数据不入库(零业务假设 + P5),真实 ZIP 不提交。 敏感数据不入库(零业务假设 + P5),真实 ZIP 不提交。
""" """
import io import io
+3 -3
View File
@@ -2,7 +2,7 @@
fixtures 全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 的块类型、 fixtures 全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 的块类型、
table/image 的嵌套 `blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位—— table/image 的嵌套 `blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位——
第三方论文内容许可不明、医疗数据不入库(零业务假设 + P5),真实 ZIP 不提交。 第三方文档内容许可不明、敏感数据不入库(零业务假设 + P5),真实 ZIP 不提交。
契约表逐行一条,参数化列明各分支。解析函数是 bytes → 结果的纯函数,失败一律 契约表逐行一条,参数化列明各分支。解析函数是 bytes → 结果的纯函数,失败一律
`ResultInvalidError`,**不得漏出原生异常**(漏出会让 `OcrClient._call` 既不写终态遥测 `ResultInvalidError`,**不得漏出原生异常**(漏出会让 `OcrClient._call` 既不写终态遥测
@@ -498,7 +498,7 @@ class TestExternalDataExceptionBoundary:
class TestLayoutPathUntouched: class TestLayoutPathUntouched:
"""文档路径更严/更宽的口径不得外溢到医院线在用的 layout 解析。""" """文档路径更严/更宽的口径不得外溢到既有调用方在用的 layout 解析。"""
def test_layout_parser_still_rejects_degenerate_int_boxes(self): def test_layout_parser_still_rejects_degenerate_int_boxes(self):
from polygateway.transports.monkey_ocr import _parse_middle_json from polygateway.transports.monkey_ocr import _parse_middle_json
@@ -510,7 +510,7 @@ class TestLayoutPathUntouched:
def test_layout_parser_keeps_its_lenient_page_idx_handling(self): def test_layout_parser_keeps_its_lenient_page_idx_handling(self):
"""layout 路径: 合法的非负整数 `page_idx` 原样接受(哪怕与页序不符),非法值才回退 """layout 路径: 合法的非负整数 `page_idx` 原样接受(哪怕与页序不符),非法值才回退
枚举下标——这正是文档路径改为严格的原因,但医院线的既有行为不得被连带改动。""" 枚举下标——这正是文档路径改为严格的原因,但既有调用方的行为不得被连带改动。"""
from polygateway.transports.monkey_ocr import _parse_middle_json from polygateway.transports.monkey_ocr import _parse_middle_json
accepted, _ = _parse_middle_json(_package([_page([_text_block()], page_idx=7)])) accepted, _ = _parse_middle_json(_package([_page([_text_block()], page_idx=7)]))
+72 -3
View File
@@ -177,6 +177,8 @@ class TestRecognizeText:
_PDF_BODY = b"%PDF-1.4\n%synthetic body" _PDF_BODY = b"%PDF-1.4\n%synthetic body"
# BMP: "BM" + 文件大小(4) + 保留(4) + 像素偏移(4) + DIB 头大小(4,常见 12/40/52/56/64/108/124)
_BMP_HEADER = b"BM" + b"\x00" * 12 + (40).to_bytes(4, "little") + b"\x00" * 20
_JUNK = b"x" * 200 _JUNK = b"x" * 200
@@ -192,7 +194,11 @@ class TestSniffMedia:
pytest.param(b"\x89PNG\r\n\x1a\n" + b"0" * 20, "image", id="png"), pytest.param(b"\x89PNG\r\n\x1a\n" + b"0" * 20, "image", id="png"),
pytest.param(b"II*\x00" + b"0" * 20, "image", id="tiff-le"), pytest.param(b"II*\x00" + b"0" * 20, "image", id="tiff-le"),
pytest.param(b"MM\x00*" + b"0" * 20, "image", id="tiff-be"), pytest.param(b"MM\x00*" + b"0" * 20, "image", id="tiff-be"),
pytest.param(b"BM" + b"0" * 20, "image", id="bmp"), pytest.param(_BMP_HEADER, "image", id="bmp"),
pytest.param(
b"BMW is a car maker, not a bitmap", "unknown", id="text-starting-with-BM"
),
pytest.param(b"BM" + b"\x00" * 40, "unknown", id="BM-with-bad-dib-header"),
pytest.param(b"GIF89a" + b"0" * 20, "image", id="gif89a"), pytest.param(b"GIF89a" + b"0" * 20, "image", id="gif89a"),
pytest.param(b"GIF87a" + b"0" * 20, "image", id="gif87a"), pytest.param(b"GIF87a" + b"0" * 20, "image", id="gif87a"),
pytest.param(b"RIFF\x00\x00\x00\x00WEBPVP8 ", "image", id="webp"), pytest.param(b"RIFF\x00\x00\x00\x00WEBPVP8 ", "image", id="webp"),
@@ -479,7 +485,7 @@ def _parsing_failed(reason):
class TestRejectedMarkers: class TestRejectedMarkers:
"""#34 设计 §6: 确定性坏输入的 500 在文档路径归「请求被拒」,其余分类一概不动。 """#34 设计 §6: 确定性坏输入的 500 在文档路径归「请求被拒」,其余分类一概不动。
共享的 `_classify_status` 默认行为字节级不变——医院线对坏图的重试/熔断行为属公共行为, 共享的 `_classify_status` 默认行为字节级不变——既有调用方对坏图的重试/熔断行为属公共行为,
推广须另行设计(issue #35)。 推广须另行设计(issue #35)。
""" """
@@ -555,7 +561,7 @@ class TestRejectedMarkers:
assert MonkeyOcrTransport(deterministic_failure_markers=custom)._rejected_markers == custom assert MonkeyOcrTransport(deterministic_failure_markers=custom)._rejected_markers == custom
async def test_layout_and_text_paths_do_not_apply_the_markers(self): async def test_layout_and_text_paths_do_not_apply_the_markers(self):
"""传输实例持有标记,但只有文档路径使用;医院线的两个方法行为不变。""" """传输实例持有标记,但只有文档路径使用;既有的两个方法行为不变。"""
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]).encode() body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]).encode()
t = _transport_for( t = _transport_for(
_routes( _routes(
@@ -569,6 +575,49 @@ class TestRejectedMarkers:
await t.parse_layout(image=b"jpg", source=_source(), call_id="c1") await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
class TestMalformedDownloadUrl:
"""`download_url` 来自服务端响应,属外部输入。畸形值(控制字符、坏方括号、越界端口)会让 httpx
抛 `InvalidURL`(不属 `HTTPError` 体系)或在连接阶段抛 ExceptionGroup,从 `_translate_http_errors`
下漏出四分类——`OcrClient._call` 只接 `PolyGatewayError`,于是不写终态遥测行、半开探针不归还。
(独立审查发现;`parse_layout` 的基线同样如此,共用的 `_post_parse` 一处修。)"""
_BAD_URLS = [
pytest.param("http://[::1", id="broken-bracket"),
pytest.param("/static/a\r\nb.zip", id="crlf"),
pytest.param("/static/a\x00.zip", id="nul"),
pytest.param("/static/a\tb.zip", id="tab"),
pytest.param("http://127.0.0.1:99999/x", id="port-out-of-range"),
]
@staticmethod
def _transport_with(download_url, seen):
def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.method, request.url.path))
if request.url.path == "/parse":
return httpx.Response(200, json=_parse_body(download_url))
return httpx.Response(200, content=b"never fetched")
return _transport_for(handler)
@pytest.mark.parametrize("download_url", _BAD_URLS)
@pytest.mark.parametrize("method", ["parse_layout", "parse_document"])
async def test_rejected_as_transient_without_fetching(self, download_url, method):
seen = []
t = self._transport_with(download_url, seen)
kwargs = {"image": b"jpg"} if method == "parse_layout" else {"document": _PDF_BODY}
with pytest.raises(TransientError, match="download_url"):
await getattr(t, method)(**kwargs, source=_source(), call_id="c1")
assert seen == [("POST", "/parse")] # 从未发出第二段请求
async def test_well_formed_urls_are_untouched(self):
for url in ("/static/ok.zip", "http://10.77.0.20:7866/static/abs.zip"):
seen = []
t = self._transport_with(url, seen)
with pytest.raises(ResultInvalidError): # 响应体不是 ZIP: 说明确实走到了第二段
await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
assert [m for m, _ in seen] == ["POST", "GET"]
class TestParseDocument: class TestParseDocument:
"""#34 设计 §5: 本地预检 → POST /parse → 流式 GET(体积上限)→ 线程内解析。""" """#34 设计 §5: 本地预检 → POST /parse → 流式 GET(体积上限)→ 线程内解析。"""
@@ -693,6 +742,26 @@ class TestParseDocument:
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1") await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert ei.value.status_code == status and ei.value.body_text == body assert ei.value.status_code == status and ei.value.body_text == body
async def test_huge_error_body_is_read_only_up_to_a_bound(self):
"""摘要只需要几 KiB;服务(或中间人)回一个巨大的错误体不该被整个读进内存。
(独立审查实测:600 MiB 的 500 响应让进程 RSS 涨到 1.8 GiB——新代码声称"流式 + 体积上限",
错误分支却是例外。)
"""
consumed = []
async def endless_error_body():
for _ in range(100_000):
consumed.append(1)
yield b"e" * 1024
t = self._transport(zip_resp=httpx.Response(500, content=endless_error_body()))
with pytest.raises(TransientError) as ei:
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert ei.value.status_code == 500
assert len(consumed) <= monkey_ocr._ERROR_BODY_READ_CAP // 1024 + 2 # 只读了上限那么多
assert ei.value.body_text.startswith("e" * 100) # 摘要仍在
async def test_error_status_with_oversized_content_length_is_still_classified_by_http( async def test_error_status_with_oversized_content_length_is_still_classified_by_http(
self, monkeypatch self, monkeypatch
): ):
+16
View File
@@ -5,6 +5,7 @@ retry_exhausted/circuit_open/stalled 三组断言即设计 §6 ③ 的 G1 契约
""" """
import asyncio import asyncio
from types import SimpleNamespace
import httpx import httpx
import pytest import pytest
@@ -826,6 +827,21 @@ class TestDocumentTelemetry:
assert OcrClient._summarize(_LAYOUT_OK) == "<elements n=1 pages=1>" assert OcrClient._summarize(_LAYOUT_OK) == "<elements n=1 pages=1>"
assert OcrClient._summarize(_TEXT_OK) == "LINE-1" assert OcrClient._summarize(_TEXT_OK) == "LINE-1"
def test_summary_keeps_structural_dispatch_for_duck_typed_results(self):
"""旧实现按结构分派(`hasattr(result, "text")`)。下游测试替身可能返回非库类型的鸭子对象,
改成 isinstance 会让它们在开启遥测后以原生 AttributeError 漏出——无登记的回归。"""
assert OcrClient._summarize(SimpleNamespace(text="hello", raw={})) == "hello"
assert (
OcrClient._summarize(SimpleNamespace(elements=[1, 2], page_sizes=[(1.0, 1.0)]))
== "<elements n=2 pages=1>"
)
assert (
OcrClient._summarize(
SimpleNamespace(elements=[1], page_sizes=[(1.0, 1.0)], markdown="abc")
)
== "<document elements=1 pages=1 md_chars=3>"
)
async def test_failed_attempt_row_carries_the_class_prefix(self): async def test_failed_attempt_row_carries_the_class_prefix(self):
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
client, _, _ = _client([_src()], [ResultInvalidError("bad package")], telemetry=recorder) client, _, _ = _client([_src()], [ResultInvalidError("bad package")], telemetry=recorder)