diff --git a/.env.example b/.env.example index 0f7ca04..96d34f4 100644 --- a/.env.example +++ b/.env.example @@ -197,7 +197,7 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填) # OCR_DOC__MONKEY__1__BASE_URL=http://10.77.0.20:7866 # OCR_DOC__MONKEY__1__API_KEY=none # OCR_DOC__MONKEY__1__MODEL=monkey-ocr -# OCR_DOC__MONKEY__1__TIMEOUT_S=600 # ≥ 最大页数 × 1.5s × 余量(共用 GPU 争用取 5 倍以上);不是总耗时上界 +# OCR_DOC__MONKEY__1__TIMEOUT_S=600 # ≥ 最大页数 × 4s × 余量(实测 1.5–4 s/页,共用 GPU 争用再乘 3–5 倍);不是总耗时上界 # OCR_DOC__MONKEY__1__MAX_CONCURRENCY=2 # 服务为 sync-only 后端时取 1(每实例 /parse 全局串行) # OCR_DOC__MONKEY__1__TRUST_ENV=false # OCR_DOC__RETRY__MAX_ATTEMPTS=2 # 超时/GET 失败的重试会重新 POST 整份文档重算,压低 diff --git a/README.md b/README.md index 2d61211..5e883d0 100644 --- a/README.md +++ b/README.md @@ -754,7 +754,7 @@ doc.markdown # 服务生成的整篇 Markdown OCR_DOC__MONKEY__1__BASE_URL=http://10.77.0.20:7866 OCR_DOC__MONKEY__1__API_KEY=none OCR_DOC__MONKEY__1__MODEL=monkey-ocr -OCR_DOC__MONKEY__1__TIMEOUT_S=600 # ≥ 最大页数 × 1.5s × 余量(共用 GPU 的争用取 5 倍以上) +OCR_DOC__MONKEY__1__TIMEOUT_S=600 # ≥ 最大页数 × 4s × 余量(实测 1.5–4 s/页;共用 GPU 的争用再乘 3–5 倍) OCR_DOC__MONKEY__1__MAX_CONCURRENCY=2 # 服务为同步串行时取 1,见下 OCR_DOC__MONKEY__1__TRUST_ENV=false OCR_DOC__RETRY__MAX_ATTEMPTS=2 @@ -771,13 +771,14 @@ OCR_DOC__CALL_DEADLINE_S=900 # 单份文档的硬上限;须 ≤ PGW | 4 | **无页数预检**: 库不依赖 PDF 解析库,发送前不知页数。数百页的文档会占满超时被重投,页数过滤是调用方的职责 | | 5 | **服务端磁盘**: 每次调用在服务 `/app/tmp` 与 `/static` 留下产物(几百篇约几百 MB 到 GB 级),库不管理,请告知服务运维 | | 6 | **多 worker 共享并发上限**需要 `PGW_LIMITER_BACKEND=redis` 且各进程同 scope;默认 memory 后端的 `MAX_CONCURRENCY` 是进程内的 | -| 7 | **服务端并发模型**: 若 MonkeyOCR 部署为 sync-only 后端(出厂默认 `lmdeploy`),`/parse` 在每个实例上**全局串行**——"并发 2"不提速,`timeout_s` 须覆盖"排队 + 处理",且一份 15 页文档(按实测约 1.5 s/页估算,约 22 秒)的队头阻塞会拖慢同实例上医院线的请求。请向运维确认 backend(服务启动日志有一行 `async-capable` / `sync-only`),sync-only 时文档线每实例并发 1、错峰运行 | +| 7 | **服务端并发模型**: 若 MonkeyOCR 部署为 sync-only 后端(出厂默认 `lmdeploy`),`/parse` 在每个实例上**全局串行**——"并发 2"不提速,`timeout_s` 须覆盖"排队 + 处理",且一份 15 页文档(按实测 1.5–4 s/页估算,约 22–60 秒)的队头阻塞会拖慢同实例上医院线的请求。请向运维确认 backend(服务启动日志有一行 `async-capable` / `sync-only`),sync-only 时文档线每实例并发 1、错峰运行 | | 8 | 源 `timeout_s` 与 permit 租约的装配守卫只比较 1×(`timeout_s ≤ lease_ttl_s`);跨 scope 的守卫改造另行立项,不在本版 | ### 已知限制 - **服务端丢弃脚注、页眉页脚、页码**(版面模型把它们标为丢弃类,span 内容为空,未被 OCR):论文表格脚注里的显著性与缩写说明不会出现在 `elements` 或 `markdown` 里,库无从补偿。 - 多行 / 多 span 的文本块、`inline_equation` span、嵌套 caption / footnote 子块这三条路径**没有真实样本**(实测样本全是 1 行 1 span,默认版面模型不产生嵌套子块),只由构造 fixture 测试。 +- **内存**: 文档路径整包上限 256 MiB(流式累计),合并分块与解析阶段会让瞬时峰值接近 2 倍上限;并发 2 时请按此预留内存。 - `parse_document` 仅接受 PDF 与魔数表内的图片(JPEG / PNG / TIFF / BMP / GIF / WebP);表外的可解码图片格式请走 `parse_layout`。 - `parse_layout` 的校验口径保持原样(bbox 整数化退化拒绝、`page_idx` 非法回退枚举下标、无体积上限);`parse_document` 更严的有零页拒绝与 `page_idx` 必须等于页序,更宽的是 bbox 只要求有限且有序。 diff --git a/research-wiki/designs/2026-10-21-issue34-ocr-document-design.md b/research-wiki/designs/2026-10-21-issue34-ocr-document-design.md index 2a14bbe..4c90c84 100644 --- a/research-wiki/designs/2026-10-21-issue34-ocr-document-design.md +++ b/research-wiki/designs/2026-10-21-issue34-ocr-document-design.md @@ -28,7 +28,7 @@ issue 把症状拆成两条;**两条同根**: 库把 OCR 输入建模成"一张 | E6 | 结果包含 `*.md`、`*_middle.json`、`*_content_list.json`、三份 `*.pdf`;**含图/表的文档另有 `images/*.jpg`**(4 页含 4 图 ≈598KB;无图的 2 页文档没有该目录) | 包体随插图增长;只读取需要的成员,不解出图片 | | E7 | `_content_list.json` 与 `_middle.json` 逐块 1:1(三样本 29/29、89/89、30/30),但 content_list **无 bbox**、type 词汇不同(`title→text+text_level`、`interline_equation→equation`),且**文本里 `$` 被转义成 `\$`**(134 个 text/title 块中 23 个) | 不用 content_list,见 §3 Q3 | | E8 | middle 的 text/title 块恰 1 行 1 span,`content` 与 content_list 反转义后 134/134 一致;table 的 span 带 `html`;interline_equation 的 span 内容为裸 LaTeX;image 嵌套 `image_body` 仅含 `image_path`;嵌套 `*_caption/*_footnote` 子块**从未出现** | 内容可只从 middle 取得;caption/footnote 路径无真实样本 | -| E9 | 速度约 1.5 s/页(2 页 3.5s,4 页 6.4s;n=3、无争用,不是 SLA)。库内**无任何基于延迟的熔断/慢判定**(`grep latency` 在 breaker/admission/sources 无命中) | 长文档不会被"判慢",真正的风险是 §7 的超时被算成失败 | +| E9 | 速度约 **1.5–4 s/页**(2026-10-21 实测:2 页 3.5s/4.0s[位图型]/5.1s/8s[issue 原文],4 页 6.2–6.4s;样本极少、无争用,不是 SLA。验证者指出初稿的「约 1.5 s/页」偏乐观,已订正)。库内**无任何基于延迟的熔断/慢判定**(`grep latency` 在 breaker/admission/sources 无命中) | 长文档不会被"判慢",真正的风险是 §7 的超时被算成失败 | | E10 | 服务源码(本机 `~/Code/MonkeyOCR`,`a4d853f`,版本 1.1.0 与样本 `_version_name` 一致)核对: ① 分派只看上传文件名后缀(白名单 `api/main.py:435-441`),`pdf` 走 `PymuDocDataset`、其余走 `ImageDataset`(`api/main.py:164-170`;后者先 `convert_to_pdf()`,`magic_pdf/data/dataset.py:231-235`)——**PDF 标成 `image.jpg` 走的是图片数据集分支**,E3 的"逐字相同"只在那份 2 页文本型 PDF 上验证,扫描件/旋转页/带批注页无证据; ② `/parse` 把一切异常(含 400)改写成 `500 "Parsing failed: …"`(`:501-503`),连 `Model not initialized` 也变成同前缀 | 支持嗅探(让 PDF 走它本该走的分支);**前缀 `Parsing failed:` 不能单独作为"坏输入"判据**,只有具体标记白名单可行 | | E11 | `content_list` 是**展示格式**: 文本经 `markdown_utils` 转义 `* \` ~ $` 四个字符(`ocr_mkcontent.py:148`);医学论文里 `*`(显著性)与 `~` 比 `$` 更常见,污染面大于 E7 的 23/134 | 强化 Q3: 绝不读 content_list 取文字 | | E12 | **服务端丢弃**脚注/页眉/页脚/页码/印章(默认版面模型 PP-DocLayout_plus-L 把这些标签映射到 `Abandon` → `discarded_blocks`,span `content` 全空,即**未被 OCR**);`figure_title` 映射为普通 `text` 块,故默认部署**不产生**嵌套 `*_caption/*_footnote` 子块(换 `doclayout_yolo` 才可能);`level` 恒为 1;`need_drop` 在该流水线硬编码为 False | 表格脚注(显著性/缩写说明)在服务端就已丢失,库找不回——写成"服务限制"告知下游;`level` 不加字段;嵌套子块仅作最小防御 | @@ -195,13 +195,13 @@ issue 把症状拆成两条;**两条同根**: 库把 OCR 输入建模成"一张 | 把"慢"变成失败的路径有两条,性质不同 | ① httpx 超时: `monkey_ocr.py:63-68` → `TransientError`(`__cause__` 为 `TimeoutException`)→ 重试 + 计熔断; ② `call_deadline_s` 到期 → `CallDeadlineExceeded`,**不计熔断**(§6) | | **`timeout_s` 不是总耗时上界**(Codex 审查纠正) | `httpx.Timeout(source.timeout_s)` 是**逐次网络操作**的超时(`httpcore` 每次读单独计时): POST 读超时≈服务处理时长上限(服务处理期间无字节),但 GET 下载只要持续有分块到达,总时长可超过任意多个 `timeout_s`。故初稿"最坏约 2×`timeout_s`"不成立,**不能据此推出租约安全** | | 租约与探针租约的真实关系 | `config.py:338-345` 只守卫 `timeout_s ≤ lease_ttl_s`(默认 1500);限流租约到期即被移除(`memory/limiter.py:98-103`),探针租约到期可被接管(`memory/breaker.py:161-163`,派生 TTL 见 `config.py:509-518`)。**硬保证只能来自 `call_deadline_s`**: 文档线应配 `call_deadline_s ≤ lease_ttl_s`,使任何一次尝试都不会活过租约; 守卫改造(跨 scope 配置语义)**不在本次**,另列 follow-up | -| 配置口径(写入 `.env.example` 与 README) | 文档线独立 scope + `MAX_CONCURRENCY=2`(满足"并发不超过 2";sync-only 见下行)+ `RETRY__MAX_ATTEMPTS=2` + `timeout_s ≥ 最大页数×1.5s×余量`(共用 GPU 争用余量取 5 倍以上)+ **`call_deadline_s ≤ lease_ttl_s` 给单份封顶**。有效熔断阈值 `max(配置, 并发×2)=4` > 单份坏文档的重试次数 | +| 配置口径(写入 `.env.example` 与 README) | 文档线独立 scope + `MAX_CONCURRENCY=2`(满足"并发不超过 2";sync-only 见下行)+ `RETRY__MAX_ATTEMPTS=2` + `timeout_s ≥ 最大页数×4s×余量`(实测 1.5–4 s/页;共用 GPU 争用再乘 3–5 倍)+ **`call_deadline_s ≤ lease_ttl_s` 给单份封顶**。有效熔断阈值 `max(配置, 并发×2)=4` > 单份坏文档的重试次数 | | 重试放大 | POST 成功(服务已算完)而 GET 失败时,重试**重新 POST 整份文档重算**; 超时重试同理,且服务端是否因断连取消计算无证据——假定不取消 | | 无页数预检 | 库不依赖 PDF 库,发送前不知页数; 300 页文档会占满超时被重投。页数过滤是下游职责 | -| 内存 | document 路径 GET 改流式并累计字节,**整包上限 256 MiB**(§5);只解必要成员,成员再受声明大小上限约束。httpx 非流式 `get` 会先 `aread()` 整包(`httpx/_client.py:1635-1637`),这是改流式的原因; `parse_layout` 不改 | +| 内存 | document 路径 GET 改流式并累计字节,**整包上限 256 MiB**(§5);只解必要成员,成员再受声明大小上限约束。**诚实标注(验证者 M-4)**: 合并分块 `b"".join` 与随后的 `BytesIO` 会让瞬时峰值接近 2 倍上限,README 已写明预留。httpx 非流式 `get` 会先 `aread()` 整包(`httpx/_client.py:1635-1637`),这是改流式的原因; `parse_layout` 不改 | | 服务端磁盘 | 每次调用在服务 `/app/tmp` 与 `/static` 留产物,几百篇约几百 MB 到 GB 级——**告知服务运维**(库不管理) | | 并发上限的进程边界 | `MAX_CONCURRENCY` 默认进程内; 多 worker 共享上限需 `PGW_LIMITER_BACKEND=redis` 且同 scope | -| **服务端串行与队头阻塞(E14)** | 若线上为 sync-only: ① 每实例 `/parse` 全局串行,"并发 2"不提速,第二份文档在服务端排队 → `timeout_s` 须覆盖"排队+处理"≈ 并发数×单份耗时×余量; ② 一份 15 页文档约 22 秒的队头阻塞可能把医院线(`table_locator`)请求推到它们自己的 `timeout_s`,触发**它们**的 Transient 与熔断——"我们的长文档误伤他们"比"他们的判定误伤我们"更现实; ③ 7867 实例与 embedding 服务同卡(部署记录),重负载互相影响,不在库治理范围。**须向运维确认 backend**(启动日志 `model_manager.py:37-38`)。指引: sync-only 时文档线每实例并发 1、错峰运行、页数过滤在调用方 | +| **服务端串行与队头阻塞(E14)** | 若线上为 sync-only: ① 每实例 `/parse` 全局串行,"并发 2"不提速,第二份文档在服务端排队 → `timeout_s` 须覆盖"排队+处理"≈ 并发数×单份耗时×余量; ② 一份 15 页文档(按 1.5–4 s/页估算约 22–60 秒)的队头阻塞可能把医院线(`table_locator`)请求推到它们自己的 `timeout_s`,触发**它们**的 Transient 与熔断——"我们的长文档误伤他们"比"他们的判定误伤我们"更现实; ③ 7867 实例与 embedding 服务同卡(部署记录),重负载互相影响,不在库治理范围。**须向运维确认 backend**(启动日志 `model_manager.py:37-38`)。指引: sync-only 时文档线每实例并发 1、错峰运行、页数过滤在调用方 | | 服务限制(告知下游) | 脚注/页眉页脚/页码在服务端即被丢弃(E12): 论文表格脚注的显著性与缩写说明不会出现在结果里; `markdown`/`elements` 均无此内容。库不能也不应补偿 | ## 8. 旧版行为审计 diff --git a/research-wiki/metrics/ocr-call-success.md b/research-wiki/metrics/ocr-call-success.md index 4d575c1..08d4d2b 100644 --- a/research-wiki/metrics/ocr-call-success.md +++ b/research-wiki/metrics/ocr-call-success.md @@ -13,5 +13,5 @@ date: 2026-07-21 - 调用成功率 = `error IS NULL` 行数 / 总行数(按 provider="monkey" 过滤,窗口按 run/日); - 错误分类分布 = `error` 非空行按异常类名前缀(Transient/SourceDead/RequestRejected/ResultInvalid/CircuitOpen/AllSourcesExhausted)归组计数; - 端点维度经 `messages` 占位前缀(`ocr:text` / `ocr:layout` / `ocr:document`)区分,或直接用 `operation` 列(`recognize_text` / `parse_layout` / `parse_document`);文档行的 `response` 摘要为 ``,**不含正文**; - - 延迟 = `latency_ms` 分位数(text、layout、document 分列——后两者含两段 HTTP,显著更慢;document 随页数增长,约 1.5 s/页[2026-10-21 实测,n=3、无争用,非 SLA],不要与 layout 混算分位数)。 + - 延迟 = `latency_ms` 分位数(text、layout、document 分列——后两者含两段 HTTP,显著更慢;document 随页数增长,约 1.5–4 s/页[2026-10-21 实测:2 页 3.5–8 秒、4 页 6.2–6.4 秒;样本极少、无争用,非 SLA],不要与 layout 混算分位数)。 - **评估基线**: 待 P7 OCR soak 首跑后建立(健康双真源池预期成功率 ≥98%,故障池口径阈值在 P7 计划冻结);单调用延迟基线以 integration 实测为初值(2026-07-21 手测: /ocr/text 约 3-5s,/parse 约 5-15s)。 diff --git a/src/polygateway/transports/_monkey_document.py b/src/polygateway/transports/_monkey_document.py index 295297c..1f0b81a 100644 --- a/src/polygateway/transports/_monkey_document.py +++ b/src/polygateway/transports/_monkey_document.py @@ -10,7 +10,7 @@ 取它会污染 LaTeX),且无 bbox、类型词汇与 middle 不同,逐块对位还会引入版本漂移 时整份失败的新故障面。 -与 layout 路径(`monkey_ocr._parse_middle_json`,医院线在用,保持原样)的三处差异: +与 layout 路径(`monkey_ocr._parse_middle_json`,既有调用方在用,保持原样)的三处差异: 零页拒绝(成功解析的文档至少 1 页)、`page_idx` 必须等于页序(layout 路径回退枚举 下标,会让 page_sizes 按 page_index 索引的契约静默错位)、bbox 只要求有限且有序 (整数化退化校验专为 CHS shim 的 `int()` 裁剪,文档消费者不裁剪,一个退化框不该 @@ -20,6 +20,7 @@ from __future__ import annotations import json +import lzma import math import zipfile import zlib @@ -41,8 +42,14 @@ MAX_MARKDOWN_BYTES = 16 * 1024 * 1024 _MIDDLE_SUFFIX = "_middle.json" +# ZIP 层「外部数据不可读」的标准库约定用法。只在 ZIP I/O 区域(构造 + 读取)内捕获, +# 不放到外层边界——宽泛捕获 RuntimeError/OSError 会掩盖编程错误: +# 加密成员 → RuntimeError;未知压缩方法/版本字段损坏 → NotImplementedError; +# DEFLATE 流损坏 → zlib.error / EOFError;BZIP2 流损坏 → OSError;LZMA 流损坏 → LZMAError +_ZIP_IO_ERRORS = (RuntimeError, NotImplementedError, zlib.error, EOFError, OSError, lzma.LZMAError) -def _finite_number(value: object) -> float: + +def finite_number(value: object) -> float: """JSON 数值 → 有限浮点;布尔与非有限数拒绝(CHS invokers.py:427 逐字)。""" if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError("字段不是数值") @@ -52,11 +59,12 @@ def _finite_number(value: object) -> float: return number -def _parse_page_size(page: dict) -> tuple[float, float]: +def parse_page_size(page: dict) -> tuple[float, float]: + """`page_size` → (宽, 高);须为 2 个正有限数(CHS invokers.py 数值防御,原样迁入)。""" page_size = page.get("page_size") if not isinstance(page_size, list) or len(page_size) != 2: raise ValueError("page_size 无效") - width, height = (_finite_number(item) for item in page_size) + width, height = (finite_number(item) for item in page_size) if width <= 0 or height <= 0: raise ValueError("page_size 必须为正数") return (width, height) @@ -67,7 +75,10 @@ def parse_document_package( ) -> tuple[list[OcrDocumentElement], list[tuple[float, float]], str]: """ZIP → (elements, page_sizes, markdown);一切形态异常归 `ResultInvalidError`。 - 外层异常边界**同时盖住「读取」与「全部结构解析」**: `_finite_number` 里 + 与 layout 路径的三处差异(理由见模块 docstring): ① 零页拒绝(成功解析的文档至少 1 页); + ② `page_idx` 必须等于页序,不回退枚举下标;③ bbox 只要求有限且有序,不做整数化退化校验。 + + 外层异常边界**同时盖住「读取」与「全部结构解析」**: `finite_number` 里 `float(10**400)` 会抛 `OverflowError`,它发生在读取之后。放错位置的后果是原生 异常漏出——`OcrClient._call` 只接 `PolyGatewayError`,于是既不写终态遥测行,又让 持有的半开探针不被归还。`CancelledError` 是 `BaseException`,天然不在其内。 @@ -91,45 +102,55 @@ def parse_document_package( def _read_members(zip_bytes: bytes) -> tuple[object, str]: - """取首个 `*_middle.json` 与按名配对的 `.md`,返回 (middle 解码对象, md 文本)。""" - with zipfile.ZipFile(BytesIO(zip_bytes)) as archive: - names = archive.namelist() - middle_names = [n for n in names if n.endswith(_MIDDLE_SUFFIX)] - if not middle_names: - raise ResultInvalidError("OCR 文档结果包缺少 _middle.json") - middle_name = middle_names[0] - # 精确配对而非"取第一个 .md": 包里可能有多个 .md,取错就是把别的文档当本文档 - markdown_name = middle_name[: -len(_MIDDLE_SUFFIX)] + ".md" - if markdown_name not in names: - raise ResultInvalidError(f"OCR 文档结果包缺少与 {middle_name} 配对的 {markdown_name}") - middle_raw = _read_member(archive, middle_name, MAX_MIDDLE_BYTES) - markdown_raw = _read_member(archive, markdown_name, MAX_MARKDOWN_BYTES) + """取首个 `*_middle.json` 与按名配对的 `.md`,返回 (middle 解码对象, md 文本)。 + + `ZipFile(...)` 的构造与成员读取在**同一个**窄 `try` 里: 独立验证发现中央目录版本字段 + 损坏会在构造阶段抛 `NotImplementedError`,而旧写法只包了成员读取。 + """ + try: + with zipfile.ZipFile(BytesIO(zip_bytes)) as archive: + middle_raw, markdown_raw = _read_pair(archive) + except _ZIP_IO_ERRORS as exc: + raise ResultInvalidError(f"OCR 文档结果包无法读取: {exc}") from exc return json.loads(middle_raw), markdown_raw.decode("utf-8-sig") +def _read_pair(archive: zipfile.ZipFile) -> tuple[bytes, bytes]: + """定位并读取 middle 与配对的 md;缺失或超限抛 `ResultInvalidError`。""" + names = archive.namelist() + middle_names = [n for n in names if n.endswith(_MIDDLE_SUFFIX)] + if not middle_names: + raise ResultInvalidError("OCR 文档结果包缺少 _middle.json") + middle_name = middle_names[0] + # 精确配对而非"取第一个 .md": 包里可能有多个 .md,取错就是把别的文档当本文档 + markdown_name = middle_name[: -len(_MIDDLE_SUFFIX)] + ".md" + if markdown_name not in names: + raise ResultInvalidError(f"OCR 文档结果包缺少与 {middle_name} 配对的 {markdown_name}") + return ( + _read_member(archive, middle_name, MAX_MIDDLE_BYTES), + _read_member(archive, markdown_name, MAX_MARKDOWN_BYTES), + ) + + def _read_member(archive: zipfile.ZipFile, name: str, cap: int) -> bytes: """读一个成员:先查**声明大小**再解压。 `zipfile` 读取不会超过声明大小(超出即 CRC 失败),所以声明大小检查足以界定内存。 - 窄 `try` 只包解压调用本身: 加密成员抛 `RuntimeError`、未知压缩方法抛 - `NotImplementedError`,这两个是标准库对"外部数据不可读"的约定用法,宽泛捕获 - `RuntimeError` 会掩盖编程错误,故不放到外层边界。 + 解压异常由调用方 `_read_members` 的窄 `try` 统一转换。 """ info = archive.getinfo(name) if info.file_size > cap: raise ResultInvalidError( f"OCR 文档结果包成员 {name} 声明大小 {info.file_size} 超过上限 {cap}" ) - try: - with archive.open(info) as stream: - return stream.read() - except (RuntimeError, NotImplementedError, zlib.error, EOFError) as exc: - raise ResultInvalidError(f"OCR 文档结果包成员 {name} 无法解压: {exc}") from exc + with archive.open(info) as stream: + return stream.read() def _parse_pages( payload: object, ) -> tuple[list[OcrDocumentElement], list[tuple[float, float]]]: + """`pdf_info` → 全部页的元素与页尺寸;零页拒绝。""" if not isinstance(payload, dict) or not isinstance(payload.get("pdf_info"), list): raise ValueError("_middle.json 缺少 pdf_info 列表") pages = payload["pdf_info"] @@ -147,9 +168,10 @@ def _parse_pages( def _parse_page( page: object, position: int ) -> tuple[list[OcrDocumentElement], tuple[float, float]]: + """单页 → (该页元素, 页尺寸);`para_blocks` 缺失按空页处理。""" if not isinstance(page, dict): raise ValueError("页面结构无效") - page_size = _parse_page_size(page) + page_size = parse_page_size(page) page_index = _page_index(page, position) blocks = page.get("para_blocks", []) # 缺失 = 空页(空白页/无可识别块)是合法"无内容" if not isinstance(blocks, list): @@ -170,6 +192,7 @@ def _page_index(page: dict, position: int) -> int: def _block_type(block: object) -> str: + """校验块是 dict 且 `type` 为非空白字符串,返回 type。""" if not isinstance(block, dict): raise ValueError("块结构无效") block_type = block.get("type") @@ -182,13 +205,14 @@ def _loose_bbox(raw: object) -> tuple[float, float, float, float]: """长度 4 的有限数值且有序(允许零宽/零高);差异理由见模块 docstring。""" if not isinstance(raw, list) or len(raw) != 4: raise ValueError("bbox 无效") - x1, y1, x2, y2 = (_finite_number(item) for item in raw) + x1, y1, x2, y2 = (finite_number(item) for item in raw) if x2 < x1 or y2 < y1: raise ValueError("bbox 边界顺序无效") return (x1, y1, x2, y2) def _sub_blocks(block: dict) -> list[dict]: + """嵌套子块列表(缺失为空);子块须是 dict 且 type 合法。""" subs = block.get("blocks", []) if not isinstance(subs, list): raise ValueError("blocks 无效") @@ -198,6 +222,7 @@ def _sub_blocks(block: dict) -> list[dict]: def _is_body(sub: dict) -> bool: + """子块是否正文部分(type 以 `_body` 结尾,如 table_body / image_body)。""" return sub["type"].endswith("_body") @@ -233,11 +258,19 @@ def _extra_elements(block: dict, page_index: int) -> list[OcrDocumentElement]: return extras +_TEXT_BEARING_TYPES = frozenset({"text", "title", "interline_equation"}) +"""必带 `lines` 的块类型。缺 `lines` 不能当作"空文本"——与 table 缺内容就报错同口径, +否则下游分不清"服务识别出空块"和"服务给了坏结构"。其余未知类型无 lines 仍是合法空文本。""" + + def _block_text(block_type: str, block: dict) -> str: + """块 → 文字;随 type 分派(table 取 html/latex,image 为空串,其余取 lines)。""" if block_type == "table": return _table_text(_sub_blocks(block)) if block_type == "image": return "" # 图内文字服务不识别;span 只有 image_path + if block_type in _TEXT_BEARING_TYPES and "lines" not in block: + raise ValueError(f"{block_type} 块缺少 lines") if "lines" in block: return _lines_text(block["lines"]) # 未知容器类型: 其 `*_body` 子块的文字并入父元素(非 body 子块已由 _extra_elements 输出) @@ -247,6 +280,7 @@ def _block_text(block_type: str, block: dict) -> str: def _table_text(subs: list[dict]) -> str: + """table 块 → 其 `table_body` 子块的 HTML(或 latex);没有表体即畸形。""" bodies = [sub for sub in subs if sub["type"] == "table_body"] if not bodies: raise ValueError("table 块缺少 table_body 子块") @@ -275,6 +309,7 @@ def _lines_text(lines: object, span_text: Callable[[dict], str] | None = None) - def _line_text(line: object, span_text: Callable[[dict], str]) -> str: + """一行 → 文字:该行全部 span 的文字直接相接。""" if not isinstance(line, dict) or not isinstance(line.get("spans"), list): raise ValueError("行结构无效") parts = [] diff --git a/src/polygateway/transports/monkey_ocr.py b/src/polygateway/transports/monkey_ocr.py index 89faae6..91352a2 100644 --- a/src/polygateway/transports/monkey_ocr.py +++ b/src/polygateway/transports/monkey_ocr.py @@ -29,9 +29,9 @@ from polygateway.transports._http_errors import ( summarize_body, ) from polygateway.transports._monkey_document import ( - _finite_number, - _parse_page_size, + finite_number, parse_document_package, + parse_page_size, ) from polygateway.types import ( OcrDocumentTransportResult, @@ -166,7 +166,7 @@ def _classify_status( 唯一的可选窄化: `rejected_markers` 非空且为 500 且命中确定性坏输入标记时,归 `RequestRejectedError`(**保留 `status_code=500`**: 服务有响应,熔断按「响应即健康」 - 记成功且不计窗口)。默认空元组 = 既有行为字节级不变——医院线对坏图的处置属公共行为, + 记成功且不计窗口)。默认空元组 = 既有行为字节级不变——既有调用方对坏图的处置属公共行为, 推广须另行设计(issue #35)。 """ status = exc.response.status_code @@ -191,7 +191,7 @@ def _classify_status( def _parse_bbox(bbox: object) -> tuple[float, float, float, float]: if not isinstance(bbox, list) or len(bbox) != 4: raise ValueError("bbox 无效") - x1, y1, x2, y2 = (_finite_number(item) for item in bbox) + x1, y1, x2, y2 = (finite_number(item) for item in bbox) if x2 <= x1 or y2 <= y1: raise ValueError("bbox 边界顺序无效") # 库返回 float 原生 bbox;整数化退化校验专为 CHS shim 的 int() 裁剪路径 @@ -220,9 +220,9 @@ def _parse_pages(payload: object) -> tuple[list[OcrLayoutElement], list[tuple[fl for fallback_index, page in enumerate(payload["pdf_info"]): if not isinstance(page, dict): raise ValueError("页面结构无效") - page_sizes.append(_parse_page_size(page)) + page_sizes.append(parse_page_size(page)) raw_index = page.get("page_idx", fallback_index) - # bool 是 int 子类、负页号无意义——与 _finite_number 拒 bool 的口径一致 + # bool 是 int 子类、负页号无意义——与 finite_number 拒 bool 的口径一致 valid_idx = ( isinstance(raw_index, int) and not isinstance(raw_index, bool) and raw_index >= 0 ) @@ -429,8 +429,10 @@ class MonkeyOcrTransport: 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() # 先落缓冲:摘要口径只认已缓冲的响应体 + if not resp.is_success: + # 先落缓冲:摘要口径只认已缓冲的响应体。条件是「非 2xx」而不是 `is_error`: + # `raise_for_status()` 对 1xx/3xx 同样会抛,漏掉它们会让摘要丢失 + await resp.aread() resp.raise_for_status() _check_declared_length(resp, source) async for chunk in resp.aiter_bytes(): diff --git a/tests/unit/test_monkey_document.py b/tests/unit/test_monkey_document.py index d666f72..5e28aad 100644 --- a/tests/unit/test_monkey_document.py +++ b/tests/unit/test_monkey_document.py @@ -11,6 +11,7 @@ table/image 的嵌套 `blocks`、span 字段),一切识别文本替换为 `SYNTH import io import json +import random import struct import zipfile @@ -287,7 +288,7 @@ class TestPageStructure: pytest.param("missing", id="missing"), pytest.param("0", id="string"), pytest.param(0.0, id="float"), - pytest.param(True, id="bool"), + pytest.param(True, id="bool-true-off-position"), pytest.param(-1, id="negative"), pytest.param(3, id="out-of-position"), ], @@ -301,6 +302,20 @@ class TestPageStructure: page["page_idx"] = page_idx _invalid([page]) + @pytest.mark.parametrize( + "pages", + [ + pytest.param([_page([], page_idx=False)], id="false-equals-0-on-first-page"), + pytest.param( + [_page([], page_idx=0), _page([], page_idx=True)], id="true-equals-1-on-second-page" + ), + ], + ) + def test_bool_page_idx_is_rejected_even_when_it_equals_the_position(self, pages): + """`False == 0`、`True == 1`: 没有显式的 bool 守卫时它们恰好等于页序而被放行。""" + with pytest.raises(ResultInvalidError, match="page_idx"): + _parse(pages) + def test_second_page_must_not_claim_the_first_index(self): _invalid([_page([], page_idx=0), _page([], page_idx=0)]) @@ -467,12 +482,15 @@ class TestExternalDataExceptionBoundary: deep = b'{"pdf_info": ' + b"[" * 100_000 + b"]" * 100_000 + b"}" _invalid(middle=deep) - @pytest.mark.parametrize("member", ["middle", "markdown"]) - def test_declared_size_over_cap_is_rejected(self, member, monkeypatch): - monkeypatch.setattr( - _monkey_document, "MAX_MIDDLE_BYTES" if member == "middle" else "MAX_MARKDOWN_BYTES", 8 - ) - _invalid([_page([_text_block("A")])], md="# a markdown longer than eight bytes") + @pytest.mark.parametrize( + ("cap_name", "member"), + [("MAX_MIDDLE_BYTES", "doc_middle.json"), ("MAX_MARKDOWN_BYTES", "doc.md")], + ) + def test_declared_size_over_cap_names_the_offending_member(self, cap_name, member, monkeypatch): + """断言消息里的成员名: 两个上限互换时,只断言异常类型的旧用例仍会通过。""" + monkeypatch.setattr(_monkey_document, cap_name, 8) + with pytest.raises(ResultInvalidError, match=member): + _parse([_page([_text_block("A")])], md="# a markdown longer than eight bytes") def test_existing_result_invalid_messages_pass_through_unchanged(self): with pytest.raises(ResultInvalidError, match="至少应有 1 页"): @@ -499,3 +517,74 @@ class TestLayoutPathUntouched: assert accepted[0].page_index == 7 fallback, _ = _parse_middle_json(_package([_page([_text_block()], page_idx="x")])) assert fallback[0].page_index == 0 + + +class TestZipLayerNeverLeaksNativeExceptions: + """独立验证发现: BZIP2/LZMA 成员数据损坏会漏出 `OSError`/`LZMAError`,中央目录版本字段损坏会在 + `ZipFile` 构造阶段漏出 `NotImplementedError`——它们都不在外层边界里。漏出的后果与设计 §5 一致: + `OcrClient._call` 只接 `PolyGatewayError`,既不写终态遥测行,也不归还半开探针。""" + + @staticmethod + def _zip(method: int) -> bytes: + payload = json.dumps({"pdf_info": [_page([_text_block("SYNTH")])]}).encode() * 20 + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=method) as zf: + zf.writestr("doc_middle.json", payload) + zf.writestr("doc.md", "# synth " * 50) + return buf.getvalue() + + @pytest.mark.parametrize( + "method", + [ + pytest.param(zipfile.ZIP_DEFLATED, id="deflated"), + pytest.param(zipfile.ZIP_BZIP2, id="bzip2"), + pytest.param(zipfile.ZIP_LZMA, id="lzma"), + ], + ) + def test_single_bit_flips_never_escape_as_native_exceptions(self, method): + """确定性字节翻转(固定种子): 每个结果只能是成功或 `ResultInvalidError`。""" + base = self._zip(method) + rng = random.Random(34) + for _ in range(600): + data = bytearray(base) + position = rng.randrange(len(data)) + data[position] ^= 1 << rng.randrange(8) + try: + parse_document_package(bytes(data)) + except ResultInvalidError: + continue + except Exception as exc: # noqa: BLE001 — 测试要抓的正是任何漏出的原生异常 + pytest.fail( + f"字节 {position} 翻转后漏出 {type(exc).__module__}.{type(exc).__name__}: {exc}" + ) + + @pytest.mark.parametrize("method", [zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA]) + def test_corrupt_non_deflate_member_data(self, method): + """压缩流内部损坏(而非 CRC 失败): 标准库抛 OSError / LZMAError。""" + data = bytearray(self._zip(method)) + local = data.index(b"PK\x03\x04") + name_len, extra_len = struct.unpack_from("= 1 + again = await inner.try_enter("m1", "check") + assert again.allowed and again.is_probe # 探针没有悬挂,下一位调用方可以再探 + terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"] + assert len(terminals) == 1 and terminals[0]["operation"] == "parse_document" + async def test_sustained_slow_download_hits_the_call_deadline_not_a_transient_error(self): """每个分块都在 read 超时内、总时长超过 call_deadline_s: 是期限,不是瞬时故障。""" closed = [] @@ -958,10 +1017,14 @@ class TestAssembly: class TestReasonlessTelemetryContract: - """text/layout 两个入口分别验证错误行不受源级推理配置污染。""" + """text/layout/document 三个入口分别验证错误行不受源级推理配置污染。 + + 独立验证发现文档入口原先不在矩阵里: 让 OCR 一律按 `reasoning_applies=True` 记账, + 15 个旧用例转红,但没有一个是 `parse_document` 的。""" @pytest.mark.parametrize( - "method,action", [("recognize_text", "text"), ("parse_layout", "layout")] + "method,action", + [("recognize_text", "text"), ("parse_layout", "layout"), ("parse_document", "document")], ) @pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}]) @pytest.mark.parametrize("backend", ["memory", "sqlite"]) @@ -994,7 +1057,7 @@ class TestReasonlessTelemetryContract: if backend == "sqlite": recorder.close() - @pytest.mark.parametrize("method", ["recognize_text", "parse_layout"]) + @pytest.mark.parametrize("method", ["recognize_text", "parse_layout", "parse_document"]) @pytest.mark.parametrize("exhausted", [False, True]) async def test_failed_attempts_still_have_null_effort(self, method, exhausted): script = (