fix: close independent verification findings on document parsing
Zip construction and member reads now share one narrow boundary so corrupt BZIP2, LZMA and version fields cannot leak native exceptions. Non-success statuses are read before classification, text-bearing blocks require lines, the size cap and reasoning contract are pinned by tests that fail under mutation, and the per-page timing claim is corrected to the observed range.
This commit is contained in:
+1
-1
@@ -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__BASE_URL=http://10.77.0.20:7866
|
||||||
# OCR_DOC__MONKEY__1__API_KEY=none
|
# OCR_DOC__MONKEY__1__API_KEY=none
|
||||||
# OCR_DOC__MONKEY__1__MODEL=monkey-ocr
|
# 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__MAX_CONCURRENCY=2 # 服务为 sync-only 后端时取 1(每实例 /parse 全局串行)
|
||||||
# OCR_DOC__MONKEY__1__TRUST_ENV=false
|
# OCR_DOC__MONKEY__1__TRUST_ENV=false
|
||||||
# OCR_DOC__RETRY__MAX_ATTEMPTS=2 # 超时/GET 失败的重试会重新 POST 整份文档重算,压低
|
# OCR_DOC__RETRY__MAX_ATTEMPTS=2 # 超时/GET 失败的重试会重新 POST 整份文档重算,压低
|
||||||
|
|||||||
@@ -754,7 +754,7 @@ doc.markdown # 服务生成的整篇 Markdown
|
|||||||
OCR_DOC__MONKEY__1__BASE_URL=http://10.77.0.20:7866
|
OCR_DOC__MONKEY__1__BASE_URL=http://10.77.0.20:7866
|
||||||
OCR_DOC__MONKEY__1__API_KEY=none
|
OCR_DOC__MONKEY__1__API_KEY=none
|
||||||
OCR_DOC__MONKEY__1__MODEL=monkey-ocr
|
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__MAX_CONCURRENCY=2 # 服务为同步串行时取 1,见下
|
||||||
OCR_DOC__MONKEY__1__TRUST_ENV=false
|
OCR_DOC__MONKEY__1__TRUST_ENV=false
|
||||||
OCR_DOC__RETRY__MAX_ATTEMPTS=2
|
OCR_DOC__RETRY__MAX_ATTEMPTS=2
|
||||||
@@ -771,13 +771,14 @@ OCR_DOC__CALL_DEADLINE_S=900 # 单份文档的硬上限;须 ≤ PGW
|
|||||||
| 4 | **无页数预检**: 库不依赖 PDF 解析库,发送前不知页数。数百页的文档会占满超时被重投,页数过滤是调用方的职责 |
|
| 4 | **无页数预检**: 库不依赖 PDF 解析库,发送前不知页数。数百页的文档会占满超时被重投,页数过滤是调用方的职责 |
|
||||||
| 5 | **服务端磁盘**: 每次调用在服务 `/app/tmp` 与 `/static` 留下产物(几百篇约几百 MB 到 GB 级),库不管理,请告知服务运维 |
|
| 5 | **服务端磁盘**: 每次调用在服务 `/app/tmp` 与 `/static` 留下产物(几百篇约几百 MB 到 GB 级),库不管理,请告知服务运维 |
|
||||||
| 6 | **多 worker 共享并发上限**需要 `PGW_LIMITER_BACKEND=redis` 且各进程同 scope;默认 memory 后端的 `MAX_CONCURRENCY` 是进程内的 |
|
| 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 的守卫改造另行立项,不在本版 |
|
| 8 | 源 `timeout_s` 与 permit 租约的装配守卫只比较 1×(`timeout_s ≤ lease_ttl_s`);跨 scope 的守卫改造另行立项,不在本版 |
|
||||||
|
|
||||||
### 已知限制
|
### 已知限制
|
||||||
|
|
||||||
- **服务端丢弃脚注、页眉页脚、页码**(版面模型把它们标为丢弃类,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 时请按此预留内存。
|
||||||
- `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 只要求有限且有序。
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ issue 把症状拆成两条;**两条同根**: 库把 OCR 输入建模成"一张
|
|||||||
| E6 | 结果包含 `*.md`、`*_middle.json`、`*_content_list.json`、三份 `*.pdf`;**含图/表的文档另有 `images/*.jpg`**(4 页含 4 图 ≈598KB;无图的 2 页文档没有该目录) | 包体随插图增长;只读取需要的成员,不解出图片 |
|
| 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 |
|
| 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 路径无真实样本 |
|
| 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:` 不能单独作为"坏输入"判据**,只有具体标记白名单可行 |
|
| 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 取文字 |
|
| 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` 不加字段;嵌套子块仅作最小防御 |
|
| 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) |
|
| 把"慢"变成失败的路径有两条,性质不同 | ① 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`"不成立,**不能据此推出租约安全** |
|
| **`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 |
|
| 租约与探针租约的真实关系 | `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 整份文档重算**; 超时重试同理,且服务端是否因断连取消计算无证据——假定不取消 |
|
| 重试放大 | POST 成功(服务已算完)而 GET 失败时,重试**重新 POST 整份文档重算**; 超时重试同理,且服务端是否因断连取消计算无证据——假定不取消 |
|
||||||
| 无页数预检 | 库不依赖 PDF 库,发送前不知页数; 300 页文档会占满超时被重投。页数过滤是下游职责 |
|
| 无页数预检 | 库不依赖 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 级——**告知服务运维**(库不管理) |
|
| 服务端磁盘 | 每次调用在服务 `/app/tmp` 与 `/static` 留产物,几百篇约几百 MB 到 GB 级——**告知服务运维**(库不管理) |
|
||||||
| 并发上限的进程边界 | `MAX_CONCURRENCY` 默认进程内; 多 worker 共享上限需 `PGW_LIMITER_BACKEND=redis` 且同 scope |
|
| 并发上限的进程边界 | `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` 均无此内容。库不能也不应补偿 |
|
| 服务限制(告知下游) | 脚注/页眉页脚/页码在服务端即被丢弃(E12): 论文表格脚注的显著性与缩写说明不会出现在结果里; `markdown`/`elements` 均无此内容。库不能也不应补偿 |
|
||||||
|
|
||||||
## 8. 旧版行为审计
|
## 8. 旧版行为审计
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ date: 2026-07-21
|
|||||||
- 调用成功率 = `error IS NULL` 行数 / 总行数(按 provider="monkey" 过滤,窗口按 run/日);
|
- 调用成功率 = `error IS NULL` 行数 / 总行数(按 provider="monkey" 过滤,窗口按 run/日);
|
||||||
- 错误分类分布 = `error` 非空行按异常类名前缀(Transient/SourceDead/RequestRejected/ResultInvalid/CircuitOpen/AllSourcesExhausted)归组计数;
|
- 错误分类分布 = `error` 非空行按异常类名前缀(Transient/SourceDead/RequestRejected/ResultInvalid/CircuitOpen/AllSourcesExhausted)归组计数;
|
||||||
- 端点维度经 `messages` 占位前缀(`ocr:text` / `ocr:layout` / `ocr:document`)区分,或直接用 `operation` 列(`recognize_text` / `parse_layout` / `parse_document`);文档行的 `response` 摘要为 `<document elements=N pages=M md_chars=K>`,**不含正文**;
|
- 端点维度经 `messages` 占位前缀(`ocr:text` / `ocr:layout` / `ocr:document`)区分,或直接用 `operation` 列(`recognize_text` / `parse_layout` / `parse_document`);文档行的 `response` 摘要为 `<document elements=N pages=M md_chars=K>`,**不含正文**;
|
||||||
- 延迟 = `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)。
|
- **评估基线**: 待 P7 OCR soak 首跑后建立(健康双真源池预期成功率 ≥98%,故障池口径阈值在 P7 计划冻结);单调用延迟基线以 integration 实测为初值(2026-07-21 手测: /ocr/text 约 3-5s,/parse 约 5-15s)。
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
取它会污染 LaTeX),且无 bbox、类型词汇与 middle 不同,逐块对位还会引入版本漂移
|
取它会污染 LaTeX),且无 bbox、类型词汇与 middle 不同,逐块对位还会引入版本漂移
|
||||||
时整份失败的新故障面。
|
时整份失败的新故障面。
|
||||||
|
|
||||||
与 layout 路径(`monkey_ocr._parse_middle_json`,医院线在用,保持原样)的三处差异:
|
与 layout 路径(`monkey_ocr._parse_middle_json`,既有调用方在用,保持原样)的三处差异:
|
||||||
零页拒绝(成功解析的文档至少 1 页)、`page_idx` 必须等于页序(layout 路径回退枚举
|
零页拒绝(成功解析的文档至少 1 页)、`page_idx` 必须等于页序(layout 路径回退枚举
|
||||||
下标,会让 page_sizes 按 page_index 索引的契约静默错位)、bbox 只要求有限且有序
|
下标,会让 page_sizes 按 page_index 索引的契约静默错位)、bbox 只要求有限且有序
|
||||||
(整数化退化校验专为 CHS shim 的 `int()` 裁剪,文档消费者不裁剪,一个退化框不该
|
(整数化退化校验专为 CHS shim 的 `int()` 裁剪,文档消费者不裁剪,一个退化框不该
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import lzma
|
||||||
import math
|
import math
|
||||||
import zipfile
|
import zipfile
|
||||||
import zlib
|
import zlib
|
||||||
@@ -41,8 +42,14 @@ MAX_MARKDOWN_BYTES = 16 * 1024 * 1024
|
|||||||
|
|
||||||
_MIDDLE_SUFFIX = "_middle.json"
|
_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 逐字)。"""
|
"""JSON 数值 → 有限浮点;布尔与非有限数拒绝(CHS invokers.py:427 逐字)。"""
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
raise ValueError("字段不是数值")
|
raise ValueError("字段不是数值")
|
||||||
@@ -52,11 +59,12 @@ def _finite_number(value: object) -> float:
|
|||||||
return number
|
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")
|
page_size = page.get("page_size")
|
||||||
if not isinstance(page_size, list) or len(page_size) != 2:
|
if not isinstance(page_size, list) or len(page_size) != 2:
|
||||||
raise ValueError("page_size 无效")
|
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:
|
if width <= 0 or height <= 0:
|
||||||
raise ValueError("page_size 必须为正数")
|
raise ValueError("page_size 必须为正数")
|
||||||
return (width, height)
|
return (width, height)
|
||||||
@@ -67,7 +75,10 @@ def parse_document_package(
|
|||||||
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]], str]:
|
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]], str]:
|
||||||
"""ZIP → (elements, page_sizes, markdown);一切形态异常归 `ResultInvalidError`。
|
"""ZIP → (elements, page_sizes, markdown);一切形态异常归 `ResultInvalidError`。
|
||||||
|
|
||||||
外层异常边界**同时盖住「读取」与「全部结构解析」**: `_finite_number` 里
|
与 layout 路径的三处差异(理由见模块 docstring): ① 零页拒绝(成功解析的文档至少 1 页);
|
||||||
|
② `page_idx` 必须等于页序,不回退枚举下标;③ bbox 只要求有限且有序,不做整数化退化校验。
|
||||||
|
|
||||||
|
外层异常边界**同时盖住「读取」与「全部结构解析」**: `finite_number` 里
|
||||||
`float(10**400)` 会抛 `OverflowError`,它发生在读取之后。放错位置的后果是原生
|
`float(10**400)` 会抛 `OverflowError`,它发生在读取之后。放错位置的后果是原生
|
||||||
异常漏出——`OcrClient._call` 只接 `PolyGatewayError`,于是既不写终态遥测行,又让
|
异常漏出——`OcrClient._call` 只接 `PolyGatewayError`,于是既不写终态遥测行,又让
|
||||||
持有的半开探针不被归还。`CancelledError` 是 `BaseException`,天然不在其内。
|
持有的半开探针不被归还。`CancelledError` 是 `BaseException`,天然不在其内。
|
||||||
@@ -91,45 +102,55 @@ def parse_document_package(
|
|||||||
|
|
||||||
|
|
||||||
def _read_members(zip_bytes: bytes) -> tuple[object, str]:
|
def _read_members(zip_bytes: bytes) -> tuple[object, str]:
|
||||||
"""取首个 `*_middle.json` 与按名配对的 `.md`,返回 (middle 解码对象, md 文本)。"""
|
"""取首个 `*_middle.json` 与按名配对的 `.md`,返回 (middle 解码对象, md 文本)。
|
||||||
with zipfile.ZipFile(BytesIO(zip_bytes)) as archive:
|
|
||||||
names = archive.namelist()
|
`ZipFile(...)` 的构造与成员读取在**同一个**窄 `try` 里: 独立验证发现中央目录版本字段
|
||||||
middle_names = [n for n in names if n.endswith(_MIDDLE_SUFFIX)]
|
损坏会在构造阶段抛 `NotImplementedError`,而旧写法只包了成员读取。
|
||||||
if not middle_names:
|
"""
|
||||||
raise ResultInvalidError("OCR 文档结果包缺少 _middle.json")
|
try:
|
||||||
middle_name = middle_names[0]
|
with zipfile.ZipFile(BytesIO(zip_bytes)) as archive:
|
||||||
# 精确配对而非"取第一个 .md": 包里可能有多个 .md,取错就是把别的文档当本文档
|
middle_raw, markdown_raw = _read_pair(archive)
|
||||||
markdown_name = middle_name[: -len(_MIDDLE_SUFFIX)] + ".md"
|
except _ZIP_IO_ERRORS as exc:
|
||||||
if markdown_name not in names:
|
raise ResultInvalidError(f"OCR 文档结果包无法读取: {exc}") from exc
|
||||||
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)
|
|
||||||
return json.loads(middle_raw), markdown_raw.decode("utf-8-sig")
|
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:
|
def _read_member(archive: zipfile.ZipFile, name: str, cap: int) -> bytes:
|
||||||
"""读一个成员:先查**声明大小**再解压。
|
"""读一个成员:先查**声明大小**再解压。
|
||||||
|
|
||||||
`zipfile` 读取不会超过声明大小(超出即 CRC 失败),所以声明大小检查足以界定内存。
|
`zipfile` 读取不会超过声明大小(超出即 CRC 失败),所以声明大小检查足以界定内存。
|
||||||
窄 `try` 只包解压调用本身: 加密成员抛 `RuntimeError`、未知压缩方法抛
|
解压异常由调用方 `_read_members` 的窄 `try` 统一转换。
|
||||||
`NotImplementedError`,这两个是标准库对"外部数据不可读"的约定用法,宽泛捕获
|
|
||||||
`RuntimeError` 会掩盖编程错误,故不放到外层边界。
|
|
||||||
"""
|
"""
|
||||||
info = archive.getinfo(name)
|
info = archive.getinfo(name)
|
||||||
if info.file_size > cap:
|
if info.file_size > cap:
|
||||||
raise ResultInvalidError(
|
raise ResultInvalidError(
|
||||||
f"OCR 文档结果包成员 {name} 声明大小 {info.file_size} 超过上限 {cap}"
|
f"OCR 文档结果包成员 {name} 声明大小 {info.file_size} 超过上限 {cap}"
|
||||||
)
|
)
|
||||||
try:
|
with archive.open(info) as stream:
|
||||||
with archive.open(info) as stream:
|
return stream.read()
|
||||||
return stream.read()
|
|
||||||
except (RuntimeError, NotImplementedError, zlib.error, EOFError) as exc:
|
|
||||||
raise ResultInvalidError(f"OCR 文档结果包成员 {name} 无法解压: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_pages(
|
def _parse_pages(
|
||||||
payload: object,
|
payload: object,
|
||||||
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]]]:
|
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]]]:
|
||||||
|
"""`pdf_info` → 全部页的元素与页尺寸;零页拒绝。"""
|
||||||
if not isinstance(payload, dict) or not isinstance(payload.get("pdf_info"), list):
|
if not isinstance(payload, dict) or not isinstance(payload.get("pdf_info"), list):
|
||||||
raise ValueError("_middle.json 缺少 pdf_info 列表")
|
raise ValueError("_middle.json 缺少 pdf_info 列表")
|
||||||
pages = payload["pdf_info"]
|
pages = payload["pdf_info"]
|
||||||
@@ -147,9 +168,10 @@ def _parse_pages(
|
|||||||
def _parse_page(
|
def _parse_page(
|
||||||
page: object, position: int
|
page: object, position: int
|
||||||
) -> tuple[list[OcrDocumentElement], tuple[float, float]]:
|
) -> tuple[list[OcrDocumentElement], tuple[float, float]]:
|
||||||
|
"""单页 → (该页元素, 页尺寸);`para_blocks` 缺失按空页处理。"""
|
||||||
if not isinstance(page, dict):
|
if not isinstance(page, dict):
|
||||||
raise ValueError("页面结构无效")
|
raise ValueError("页面结构无效")
|
||||||
page_size = _parse_page_size(page)
|
page_size = parse_page_size(page)
|
||||||
page_index = _page_index(page, position)
|
page_index = _page_index(page, position)
|
||||||
blocks = page.get("para_blocks", []) # 缺失 = 空页(空白页/无可识别块)是合法"无内容"
|
blocks = page.get("para_blocks", []) # 缺失 = 空页(空白页/无可识别块)是合法"无内容"
|
||||||
if not isinstance(blocks, list):
|
if not isinstance(blocks, list):
|
||||||
@@ -170,6 +192,7 @@ def _page_index(page: dict, position: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _block_type(block: object) -> str:
|
def _block_type(block: object) -> str:
|
||||||
|
"""校验块是 dict 且 `type` 为非空白字符串,返回 type。"""
|
||||||
if not isinstance(block, dict):
|
if not isinstance(block, dict):
|
||||||
raise ValueError("块结构无效")
|
raise ValueError("块结构无效")
|
||||||
block_type = block.get("type")
|
block_type = block.get("type")
|
||||||
@@ -182,13 +205,14 @@ def _loose_bbox(raw: object) -> tuple[float, float, float, float]:
|
|||||||
"""长度 4 的有限数值且有序(允许零宽/零高);差异理由见模块 docstring。"""
|
"""长度 4 的有限数值且有序(允许零宽/零高);差异理由见模块 docstring。"""
|
||||||
if not isinstance(raw, list) or len(raw) != 4:
|
if not isinstance(raw, list) or len(raw) != 4:
|
||||||
raise ValueError("bbox 无效")
|
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:
|
if x2 < x1 or y2 < y1:
|
||||||
raise ValueError("bbox 边界顺序无效")
|
raise ValueError("bbox 边界顺序无效")
|
||||||
return (x1, y1, x2, y2)
|
return (x1, y1, x2, y2)
|
||||||
|
|
||||||
|
|
||||||
def _sub_blocks(block: dict) -> list[dict]:
|
def _sub_blocks(block: dict) -> list[dict]:
|
||||||
|
"""嵌套子块列表(缺失为空);子块须是 dict 且 type 合法。"""
|
||||||
subs = block.get("blocks", [])
|
subs = block.get("blocks", [])
|
||||||
if not isinstance(subs, list):
|
if not isinstance(subs, list):
|
||||||
raise ValueError("blocks 无效")
|
raise ValueError("blocks 无效")
|
||||||
@@ -198,6 +222,7 @@ def _sub_blocks(block: dict) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _is_body(sub: dict) -> bool:
|
def _is_body(sub: dict) -> bool:
|
||||||
|
"""子块是否正文部分(type 以 `_body` 结尾,如 table_body / image_body)。"""
|
||||||
return sub["type"].endswith("_body")
|
return sub["type"].endswith("_body")
|
||||||
|
|
||||||
|
|
||||||
@@ -233,11 +258,19 @@ def _extra_elements(block: dict, page_index: int) -> list[OcrDocumentElement]:
|
|||||||
return extras
|
return extras
|
||||||
|
|
||||||
|
|
||||||
|
_TEXT_BEARING_TYPES = frozenset({"text", "title", "interline_equation"})
|
||||||
|
"""必带 `lines` 的块类型。缺 `lines` 不能当作"空文本"——与 table 缺内容就报错同口径,
|
||||||
|
否则下游分不清"服务识别出空块"和"服务给了坏结构"。其余未知类型无 lines 仍是合法空文本。"""
|
||||||
|
|
||||||
|
|
||||||
def _block_text(block_type: str, block: dict) -> str:
|
def _block_text(block_type: str, block: dict) -> str:
|
||||||
|
"""块 → 文字;随 type 分派(table 取 html/latex,image 为空串,其余取 lines)。"""
|
||||||
if block_type == "table":
|
if block_type == "table":
|
||||||
return _table_text(_sub_blocks(block))
|
return _table_text(_sub_blocks(block))
|
||||||
if block_type == "image":
|
if block_type == "image":
|
||||||
return "" # 图内文字服务不识别;span 只有 image_path
|
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:
|
if "lines" in block:
|
||||||
return _lines_text(block["lines"])
|
return _lines_text(block["lines"])
|
||||||
# 未知容器类型: 其 `*_body` 子块的文字并入父元素(非 body 子块已由 _extra_elements 输出)
|
# 未知容器类型: 其 `*_body` 子块的文字并入父元素(非 body 子块已由 _extra_elements 输出)
|
||||||
@@ -247,6 +280,7 @@ def _block_text(block_type: str, block: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _table_text(subs: list[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"]
|
bodies = [sub for sub in subs if sub["type"] == "table_body"]
|
||||||
if not bodies:
|
if not bodies:
|
||||||
raise ValueError("table 块缺少 table_body 子块")
|
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:
|
def _line_text(line: object, span_text: Callable[[dict], str]) -> str:
|
||||||
|
"""一行 → 文字:该行全部 span 的文字直接相接。"""
|
||||||
if not isinstance(line, dict) or not isinstance(line.get("spans"), list):
|
if not isinstance(line, dict) or not isinstance(line.get("spans"), list):
|
||||||
raise ValueError("行结构无效")
|
raise ValueError("行结构无效")
|
||||||
parts = []
|
parts = []
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ from polygateway.transports._http_errors import (
|
|||||||
summarize_body,
|
summarize_body,
|
||||||
)
|
)
|
||||||
from polygateway.transports._monkey_document import (
|
from polygateway.transports._monkey_document import (
|
||||||
_finite_number,
|
finite_number,
|
||||||
_parse_page_size,
|
|
||||||
parse_document_package,
|
parse_document_package,
|
||||||
|
parse_page_size,
|
||||||
)
|
)
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
OcrDocumentTransportResult,
|
OcrDocumentTransportResult,
|
||||||
@@ -166,7 +166,7 @@ def _classify_status(
|
|||||||
|
|
||||||
唯一的可选窄化: `rejected_markers` 非空且为 500 且命中确定性坏输入标记时,归
|
唯一的可选窄化: `rejected_markers` 非空且为 500 且命中确定性坏输入标记时,归
|
||||||
`RequestRejectedError`(**保留 `status_code=500`**: 服务有响应,熔断按「响应即健康」
|
`RequestRejectedError`(**保留 `status_code=500`**: 服务有响应,熔断按「响应即健康」
|
||||||
记成功且不计窗口)。默认空元组 = 既有行为字节级不变——医院线对坏图的处置属公共行为,
|
记成功且不计窗口)。默认空元组 = 既有行为字节级不变——既有调用方对坏图的处置属公共行为,
|
||||||
推广须另行设计(issue #35)。
|
推广须另行设计(issue #35)。
|
||||||
"""
|
"""
|
||||||
status = exc.response.status_code
|
status = exc.response.status_code
|
||||||
@@ -191,7 +191,7 @@ def _classify_status(
|
|||||||
def _parse_bbox(bbox: object) -> tuple[float, float, float, float]:
|
def _parse_bbox(bbox: object) -> tuple[float, float, float, float]:
|
||||||
if not isinstance(bbox, list) or len(bbox) != 4:
|
if not isinstance(bbox, list) or len(bbox) != 4:
|
||||||
raise ValueError("bbox 无效")
|
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:
|
if x2 <= x1 or y2 <= y1:
|
||||||
raise ValueError("bbox 边界顺序无效")
|
raise ValueError("bbox 边界顺序无效")
|
||||||
# 库返回 float 原生 bbox;整数化退化校验专为 CHS shim 的 int() 裁剪路径
|
# 库返回 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"]):
|
for fallback_index, page in enumerate(payload["pdf_info"]):
|
||||||
if not isinstance(page, dict):
|
if not isinstance(page, dict):
|
||||||
raise ValueError("页面结构无效")
|
raise ValueError("页面结构无效")
|
||||||
page_sizes.append(_parse_page_size(page))
|
page_sizes.append(parse_page_size(page))
|
||||||
raw_index = page.get("page_idx", fallback_index)
|
raw_index = page.get("page_idx", fallback_index)
|
||||||
# bool 是 int 子类、负页号无意义——与 _finite_number 拒 bool 的口径一致
|
# bool 是 int 子类、负页号无意义——与 finite_number 拒 bool 的口径一致
|
||||||
valid_idx = (
|
valid_idx = (
|
||||||
isinstance(raw_index, int) and not isinstance(raw_index, bool) and raw_index >= 0
|
isinstance(raw_index, int) and not isinstance(raw_index, bool) and raw_index >= 0
|
||||||
)
|
)
|
||||||
@@ -429,8 +429,10 @@ class MonkeyOcrTransport:
|
|||||||
total = 0
|
total = 0
|
||||||
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 resp.is_error:
|
if not resp.is_success:
|
||||||
await resp.aread() # 先落缓冲:摘要口径只认已缓冲的响应体
|
# 先落缓冲:摘要口径只认已缓冲的响应体。条件是「非 2xx」而不是 `is_error`:
|
||||||
|
# `raise_for_status()` 对 1xx/3xx 同样会抛,漏掉它们会让摘要丢失
|
||||||
|
await resp.aread()
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
_check_declared_length(resp, source)
|
_check_declared_length(resp, source)
|
||||||
async for chunk in resp.aiter_bytes():
|
async for chunk in resp.aiter_bytes():
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ table/image 的嵌套 `blocks`、span 字段),一切识别文本替换为 `SYNTH
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
import struct
|
import struct
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
@@ -287,7 +288,7 @@ class TestPageStructure:
|
|||||||
pytest.param("missing", id="missing"),
|
pytest.param("missing", id="missing"),
|
||||||
pytest.param("0", id="string"),
|
pytest.param("0", id="string"),
|
||||||
pytest.param(0.0, id="float"),
|
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(-1, id="negative"),
|
||||||
pytest.param(3, id="out-of-position"),
|
pytest.param(3, id="out-of-position"),
|
||||||
],
|
],
|
||||||
@@ -301,6 +302,20 @@ class TestPageStructure:
|
|||||||
page["page_idx"] = page_idx
|
page["page_idx"] = page_idx
|
||||||
_invalid([page])
|
_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):
|
def test_second_page_must_not_claim_the_first_index(self):
|
||||||
_invalid([_page([], page_idx=0), _page([], page_idx=0)])
|
_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"}"
|
deep = b'{"pdf_info": ' + b"[" * 100_000 + b"]" * 100_000 + b"}"
|
||||||
_invalid(middle=deep)
|
_invalid(middle=deep)
|
||||||
|
|
||||||
@pytest.mark.parametrize("member", ["middle", "markdown"])
|
@pytest.mark.parametrize(
|
||||||
def test_declared_size_over_cap_is_rejected(self, member, monkeypatch):
|
("cap_name", "member"),
|
||||||
monkeypatch.setattr(
|
[("MAX_MIDDLE_BYTES", "doc_middle.json"), ("MAX_MARKDOWN_BYTES", "doc.md")],
|
||||||
_monkey_document, "MAX_MIDDLE_BYTES" if member == "middle" else "MAX_MARKDOWN_BYTES", 8
|
)
|
||||||
)
|
def test_declared_size_over_cap_names_the_offending_member(self, cap_name, member, monkeypatch):
|
||||||
_invalid([_page([_text_block("A")])], md="# a markdown longer than eight bytes")
|
"""断言消息里的成员名: 两个上限互换时,只断言异常类型的旧用例仍会通过。"""
|
||||||
|
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):
|
def test_existing_result_invalid_messages_pass_through_unchanged(self):
|
||||||
with pytest.raises(ResultInvalidError, match="至少应有 1 页"):
|
with pytest.raises(ResultInvalidError, match="至少应有 1 页"):
|
||||||
@@ -499,3 +517,74 @@ class TestLayoutPathUntouched:
|
|||||||
assert accepted[0].page_index == 7
|
assert accepted[0].page_index == 7
|
||||||
fallback, _ = _parse_middle_json(_package([_page([_text_block()], page_idx="x")]))
|
fallback, _ = _parse_middle_json(_package([_page([_text_block()], page_idx="x")]))
|
||||||
assert fallback[0].page_index == 0
|
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("<HH", data, local + 26)
|
||||||
|
body = local + 30 + name_len + extra_len
|
||||||
|
for offset in range(body, body + 12):
|
||||||
|
data[offset] ^= 0xFF
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(bytes(data))
|
||||||
|
|
||||||
|
def test_zipfile_construction_stage_is_inside_the_boundary(self):
|
||||||
|
"""中央目录的 extract_version 损坏: `ZipFile(...)` 构造阶段抛 NotImplementedError。"""
|
||||||
|
data = bytearray(self._zip(zipfile.ZIP_STORED))
|
||||||
|
central = data.index(b"PK\x01\x02")
|
||||||
|
data[central + 6] = 99 # extract_version = 9.9 → "zip file version 9.9"
|
||||||
|
with pytest.raises(ResultInvalidError):
|
||||||
|
parse_document_package(bytes(data))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextBearingBlocksRequireLines:
|
||||||
|
"""text/title/interline_equation 缺 `lines` 不是「空文本」而是畸形: 与 table 缺内容就报错同口径,
|
||||||
|
否则下游分不清「服务识别出空块」和「服务给了坏结构」。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("block_type", ["text", "title", "interline_equation"])
|
||||||
|
def test_missing_lines_is_rejected_with_its_reason(self, block_type):
|
||||||
|
block = {"type": block_type, "bbox": list(_BBOX)}
|
||||||
|
with pytest.raises(ResultInvalidError, match="缺少 lines"):
|
||||||
|
_parse([_page([block])])
|
||||||
|
|||||||
@@ -704,27 +704,83 @@ class TestParseDocument:
|
|||||||
document=_PDF_BODY, source=_source(), call_id="c1"
|
document=_PDF_BODY, source=_source(), call_id="c1"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_oversized_declared_content_length_is_rejected(self, monkeypatch):
|
@staticmethod
|
||||||
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 100)
|
def _oversized_valid_package():
|
||||||
resp = httpx.Response(200, content=b"x" * 10, headers={"content-length": "101"})
|
"""**合法**的 ZIP 但整体超过上限: 去掉上限检查时它能被正常解析,用例才会转红。
|
||||||
with pytest.raises(ResultInvalidError):
|
|
||||||
|
(独立验证发现: 旧用例用非 ZIP 垃圾字节,去掉上限后同样抛 ResultInvalidError——
|
||||||
|
走的是 BadZipFile 路径,上限从未被测到。)
|
||||||
|
"""
|
||||||
|
return _doc_package(
|
||||||
|
[_doc_page([_text_block("SYNTH-A")])], extra=[("images/big.jpg", b"x" * 5000)]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _assert_cap_rejects_before_parsing(self, monkeypatch, resp):
|
||||||
|
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 2000)
|
||||||
|
parsed = []
|
||||||
|
|
||||||
|
def spy(package):
|
||||||
|
parsed.append(len(package))
|
||||||
|
return [], [(1.0, 1.0)], ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(monkey_ocr, "parse_document_package", spy)
|
||||||
|
with pytest.raises(ResultInvalidError, match="体积超过上限"):
|
||||||
await self._transport(zip_resp=resp).parse_document(
|
await self._transport(zip_resp=resp).parse_document(
|
||||||
document=_PDF_BODY, source=_source(), call_id="c1"
|
document=_PDF_BODY, source=_source(), call_id="c1"
|
||||||
)
|
)
|
||||||
|
assert parsed == [] # 解析函数根本没被调用
|
||||||
|
|
||||||
async def test_oversized_streamed_body_is_rejected_by_cumulative_count(self, monkeypatch):
|
async def test_oversized_valid_package_is_rejected_by_declared_content_length(
|
||||||
"""没有 Content-Length(分块传输)时靠累计字节兜底: 多个小块合起来超限。"""
|
self, monkeypatch
|
||||||
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 100)
|
):
|
||||||
|
resp = httpx.Response(200, content=self._oversized_valid_package()) # 自动带 Content-Length
|
||||||
|
await self._assert_cap_rejects_before_parsing(monkeypatch, resp)
|
||||||
|
|
||||||
|
async def test_oversized_valid_package_is_rejected_by_cumulative_count(self, monkeypatch):
|
||||||
|
"""没有 Content-Length(分块传输)时靠累计字节兜底。"""
|
||||||
|
package = self._oversized_valid_package()
|
||||||
|
|
||||||
async def chunks():
|
async def chunks():
|
||||||
for _ in range(3):
|
for offset in range(0, len(package), 700):
|
||||||
yield b"x" * 60
|
yield package[offset : offset + 700]
|
||||||
|
|
||||||
resp = httpx.Response(200, content=chunks())
|
await self._assert_cap_rejects_before_parsing(
|
||||||
with pytest.raises(ResultInvalidError):
|
monkeypatch, httpx.Response(200, content=chunks())
|
||||||
await self._transport(zip_resp=resp).parse_document(
|
)
|
||||||
document=_PDF_BODY, source=_source(), call_id="c1"
|
|
||||||
)
|
async def test_package_under_the_cap_still_parses(self, monkeypatch):
|
||||||
|
"""对照: 上限只拦超限的,不误伤正常包。"""
|
||||||
|
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 10_000)
|
||||||
|
result = await self._transport(
|
||||||
|
zip_resp=httpx.Response(200, content=self._oversized_valid_package())
|
||||||
|
).parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
|
||||||
|
assert [e.text for e in result.elements] == ["SYNTH-A"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status", "exc_type"),
|
||||||
|
[
|
||||||
|
(401, SourceDeadError),
|
||||||
|
(404, RequestRejectedError),
|
||||||
|
(500, TransientError),
|
||||||
|
(302, RequestRejectedError), # 3xx 不是 `is_error`,但 raise_for_status 会抛
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_streamed_error_body_is_read_before_classification(self, status, exc_type):
|
||||||
|
"""真正的流式响应(异步迭代器 body)不会被预缓冲: 不先 `aread()`,摘要读到的是空串。
|
||||||
|
|
||||||
|
(独立验证发现: `httpx.Response(content=bytes)` 已预缓冲,删掉 `aread()` 后旧用例仍绿;
|
||||||
|
且旧条件 `is_error` 漏掉 3xx。)
|
||||||
|
"""
|
||||||
|
body = '{"detail": "synthetic upstream reason"}'
|
||||||
|
|
||||||
|
async def streamed():
|
||||||
|
yield body.encode()
|
||||||
|
|
||||||
|
t = self._transport(zip_resp=httpx.Response(status, content=streamed()))
|
||||||
|
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
|
||||||
|
assert ei.value.body_text == body
|
||||||
|
|
||||||
async def test_bad_package_is_result_invalid(self):
|
async def test_bad_package_is_result_invalid(self):
|
||||||
t = self._transport(zip_resp=httpx.Response(200, content=b"not a zip"))
|
t = self._transport(zip_resp=httpx.Response(200, content=b"not a zip"))
|
||||||
|
|||||||
@@ -670,6 +670,15 @@ class TestParseDocument:
|
|||||||
assert r.markdown == "# SYNTH-MD"
|
assert r.markdown == "# SYNTH-MD"
|
||||||
assert client._selector.outcomes == [("m1", False), ("m1", True)]
|
assert client._selector.outcomes == [("m1", False), ("m1", True)]
|
||||||
|
|
||||||
|
async def test_source_dead_switches_to_the_second_source_immediately(self):
|
||||||
|
s1, s2 = _src(name="m1"), _src(name="m2", base_url="http://10.77.0.20:7867")
|
||||||
|
client, _, _ = _client(
|
||||||
|
[s1, s2], [SourceDeadError("401", status_code=401), "document"], sleep=_no_sleep
|
||||||
|
)
|
||||||
|
r = await client.parse_document(b"%PDF-1.4")
|
||||||
|
assert r.source_name == "m2"
|
||||||
|
assert client._selector.outcomes == [("m1", False), ("m2", True)]
|
||||||
|
|
||||||
async def test_result_invalid_counts_no_attempt_and_no_feed(self):
|
async def test_result_invalid_counts_no_attempt_and_no_feed(self):
|
||||||
client, _, gate = _client([_src()], [ResultInvalidError("bad package")])
|
client, _, gate = _client([_src()], [ResultInvalidError("bad package")])
|
||||||
with pytest.raises(ResultInvalidError):
|
with pytest.raises(ResultInvalidError):
|
||||||
@@ -742,6 +751,10 @@ class TestParseDocument:
|
|||||||
assert (await limiter.source_stats("m1")).inflight == 0
|
assert (await limiter.source_stats("m1")).inflight == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _no_sleep(_delay):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
class TestNoLatencyJudgement:
|
class TestNoLatencyJudgement:
|
||||||
"""长文档不会被判慢(设计 §7): 库内没有任何基于延迟的熔断/选源判定。
|
"""长文档不会被判慢(设计 §7): 库内没有任何基于延迟的熔断/选源判定。
|
||||||
|
|
||||||
@@ -893,6 +906,52 @@ class TestDocumentThroughTheRealTransport:
|
|||||||
assert after.allowed and not after.is_probe # 探针已按"坏结果≠坏服务"结算,没有悬挂
|
assert after.allowed and not after.is_probe # 探针已按"坏结果≠坏服务"结算,没有悬挂
|
||||||
assert inner._gates["m1"].fails == 0
|
assert inner._gates["m1"].fails == 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _slow_download_handler():
|
||||||
|
async def slow_body():
|
||||||
|
for _ in range(200):
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield b"x" * 8
|
||||||
|
|
||||||
|
return _doc_handler(download=lambda: httpx.Response(200, content=slow_body()))
|
||||||
|
|
||||||
|
async def test_per_call_deadline_overrides_the_assembly_value(self):
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src(timeout_s=120.0)], [], transport=_real_transport(self._slow_download_handler())
|
||||||
|
) # 装配值未设期限
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.parse_document(b"%PDF-1.4 synthetic", call_deadline_s=0.1)
|
||||||
|
|
||||||
|
async def test_deadline_while_holding_the_half_open_probe_returns_it_and_writes_a_terminal_row(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
"""期限到期取消在飞请求时: 探针必须归还,且终态遥测行必须写出(遥测必录)。"""
|
||||||
|
clock = FakeClock()
|
||||||
|
inner = InMemoryGate(
|
||||||
|
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0), now=clock
|
||||||
|
)
|
||||||
|
entry = await inner.try_enter("m1", "setup")
|
||||||
|
await inner.record_failure(entry, "source_dead", True)
|
||||||
|
clock.advance(61.0) # 半开: 本次调用持有探针
|
||||||
|
gate = RecordingGate(inner)
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
client, _, _ = _client(
|
||||||
|
[_src(timeout_s=120.0)],
|
||||||
|
[],
|
||||||
|
now=clock,
|
||||||
|
breaker=gate,
|
||||||
|
telemetry=recorder,
|
||||||
|
transport=_real_transport(self._slow_download_handler()),
|
||||||
|
call_deadline_s=0.1,
|
||||||
|
)
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.parse_document(b"%PDF-1.4 synthetic")
|
||||||
|
assert gate.probe_releases >= 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):
|
async def test_sustained_slow_download_hits_the_call_deadline_not_a_transient_error(self):
|
||||||
"""每个分块都在 read 超时内、总时长超过 call_deadline_s: 是期限,不是瞬时故障。"""
|
"""每个分块都在 read 超时内、总时长超过 call_deadline_s: 是期限,不是瞬时故障。"""
|
||||||
closed = []
|
closed = []
|
||||||
@@ -958,10 +1017,14 @@ class TestAssembly:
|
|||||||
|
|
||||||
|
|
||||||
class TestReasonlessTelemetryContract:
|
class TestReasonlessTelemetryContract:
|
||||||
"""text/layout 两个入口分别验证错误行不受源级推理配置污染。"""
|
"""text/layout/document 三个入口分别验证错误行不受源级推理配置污染。
|
||||||
|
|
||||||
|
独立验证发现文档入口原先不在矩阵里: 让 OCR 一律按 `reasoning_applies=True` 记账,
|
||||||
|
15 个旧用例转红,但没有一个是 `parse_document` 的。"""
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
|
||||||
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
|
@pytest.mark.parametrize("backend", ["memory", "sqlite"])
|
||||||
@@ -994,7 +1057,7 @@ class TestReasonlessTelemetryContract:
|
|||||||
if backend == "sqlite":
|
if backend == "sqlite":
|
||||||
recorder.close()
|
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])
|
@pytest.mark.parametrize("exhausted", [False, True])
|
||||||
async def test_failed_attempts_still_have_null_effort(self, method, exhausted):
|
async def test_failed_attempts_still_have_null_effort(self, method, exhausted):
|
||||||
script = (
|
script = (
|
||||||
|
|||||||
Reference in New Issue
Block a user