Files
PolyGateway/research-wiki/designs/2026-10-21-issue34-ocr-document-design.md
T
iomgaa 4117eae501 docs: add OCR document parsing design for issue 34
Root-cause analysis from live MonkeyOCR probes: upload label is not detected, output carries no content, and deterministic bad-input 500s are treated as transient. Proposes parse_document with content-sniffed upload type and middle.json-only extraction.
2026-10-21 21:55:01 -04:00

235 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: design
node_id: design:2026-10-21-issue34-ocr-document-design
title: OCR 整份文档解析设计(#34)
date: 2026-10-21
---
# #34 OCR 整份文档解析设计
> 状态: **待人类审批**(公共 API + 端口新增,强制人类门)。已完成 Claude 自审与一轮独立 subagent 论证(§12);Codex 审查见 §12 状态。
> 基线: `25a90eb`(1.3.10);目标版本 **1.3.11**;分支 `fix/issue34-ocr-document`。
> 需求: [#34](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/34)(CHSAnalyzer3 把几百篇医学论文 PDF 整份转成带页码正文与表格)。
> 修订对象: [M3 OCR 设计](2026-07-21-m3-ocr-design.md) §4 "multipart 固定名 image.jpg,服务按内容处理"(该论断只在图片样本上验证过,见 §1 E3/E4)、ARCHITECTURE §7.10。
## 1. 问题与根因
issue 把症状拆成两条;**两条同根**: 库把 OCR 输入建模成"一张图 + 写死的标签",把输出建模成"只有框",**没有"文档"这个概念**——输入类型既不声明也不检测,输出不含内容。M3 设计 §4 的"服务按内容处理"是从 35 张图片样本外推到 PDF 的未验证假设。
以下事实 2026-10-21 在真实 MonkeyOCR 服务实测取得(样本为第三方 AI 生成论文,仅本地存证,**不入库**):
| # | 事实 | 设计影响 |
| --- | --- | --- |
| E1 | 服务按**扩展名**分派,仅 `.jpg/.jpeg/.png/.pdf`(大小写不敏感);其余(`.webp`/无扩展名)→ HTTP 500 `{"detail":"Parsing failed: 400: Unsupported file type…"}` | 上传标签必须落在这四个之内;服务把客户端错误包成 500 |
| E2 | 内容与扩展名不符(jpg 字节标 `doc.pdf`)→ HTTP 500 `Parsing failed: is no PDF` | **调用方声明类型会新增一个失败入口**,故类型由库按内容判定 |
| E3 | `/parse`: 同一 PDF 以 `image.jpg` 与 `doc.pdf` 上传,`_middle.json`/`_content_list.json` **逐字相同**(只有 message 与包内文件名前缀不同);PNG/TIFF/BMP 字节标 `image.jpg` 亦成功(服务按内容解码) | 问题一在 `/parse` 上**不改变结果**;非 PDF 沿用 `image.jpg` 才是零回归 |
| E4 | `/ocr/text`: PDF 字节标 `image.jpg` → HTTP 200 `success:true`,content=`"The image contains a single character: 1"`(**静默错误结果**);标 `doc.pdf` → 整份全文(无页码) | 问题一的真实危害在 `/ocr/text`,issue 未提及;须一并修 |
| E5 | 截断 PDF / 伪 `%PDF-` 头 / 乱码图 / 空文件 → 全部 **HTTP 500** `{"detail":"Parsing failed: <原因>"}` | 当前 ≥500 一律 `TransientError`,见 §6 |
| 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 的超时被算成失败 |
## 2. 现状代码面
| 位置 | 现状 |
| --- | --- |
| `transports/monkey_ocr.py:207-210` | `_files()` 固定 `("image.jpg", …, "image/jpeg")`,`/ocr/text` 与 `/parse` 共用 |
| `monkey_ocr.py:136-144,147-166` | `_parse_block` 只取 type/bbox;`_parse_pages` 对非法 `page_idx` 回退枚举下标;bbox 含 CHS shim 专用的整数化退化校验 |
| `monkey_ocr.py:79-100` | `_classify_status`: ≥500/429→Transient、401/403→SourceDead、其余→RequestRejected |
| `types.py:1187-1250` | `OcrLayoutElement(type,bbox,page_index)`、`OcrLayoutResult`、`OcrLayoutTransportResult` |
| `ocr.py:625-628` | `_summarize` 用 `hasattr(result,"text")` 区分文本/版面——**对新结果类型是陷阱** |
| `ports.py:105-121` | `OcrTransport` 是 `@runtime_checkable`,加成员会让所有现有实现(测试替身、下游 fake)在 `isinstance` 上不合格 |
## 3. 备选方案对比
### Q1 公共面形态
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | 新增 `OcrClient.parse_document` + `OcrDocumentPort`;新类型 `OcrDocumentElement(OcrLayoutElement)`(仅增 `text`)与 `OcrDocumentResult`;`parse_layout`/`recognize_text` 签名与返回类型不动 | 医院线零风险;文档线的内容解析故障不波及版面路径 |
| B | 给 `OcrLayoutElement` 追加 `text` 字段,`parse_layout` 同时返回内容 | 违反 issue "parse_layout 不动";`text: str\|None` 在一条路径上恒为 None;医院线耦合到新增的包成员解析,任一故障波及它 |
| C | 只加 `filename/media_type` 参数并把 ZIP 原样交给调用方 | 把校验、数值防御、阅读序整理全推给下游,违背库的价值;每个下游各写一份解析 |
### Q2 上传类型如何确定
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | transport 按魔数嗅探: 以 `%PDF-` 开头 → `doc.pdf`/`application/pdf`;**其余一律沿用 `image.jpg`/`image/jpeg`**。单点 `_files()`,两端点一并生效 | 调用方无从声明错;非 PDF 路径字节级不变(E3 零回归);顺带修 E4 |
| B | 调用方传 `media_type`/`filename` | 声明与内容不符 → E2 的 500 → Transient → 重试并计熔断: 笔误变熔断事件 |
| C | 嗅探全部图片格式并精确标注(png→`image.png`) | 零收益;webp/gif 等无对应扩展名反而要拒绝,把现在能成功的路径(E3)变成失败 |
残留: PDF 规范允许 `%PDF-` 前有至多 1024 字节垃圾,严格 `startswith` 会让此类文件落到 `image.jpg`(`/parse` 仍按内容解码、`/ocr/text` 仍是 E4)。**有意接受**(放宽到前 1KB 搜索会误判含 XMP 元数据的图片),文档注明。
### Q3 文字内容从哪里来
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | **只读 `_middle.json`**(已是 bbox/type/页码/阅读序的来源),从 `lines[].spans[]` 取 `content`/`html`;`.md` 原文单独取 | 单一来源,无对位、无类型映射、无版本漂移故障面;不受 `\$` 转义污染 |
| B | `_content_list.json` 取文字 + middle 取 bbox,按页内顺序对位并严格校验(本设计初稿方案) | 1:1 是上游构造性成立但**无契约**;漂移时整份 `ResultInvalid`,几百篇批处理会"一批全挂";文本含 `\$` 需反转义,且 title 的 level 等字段样本中无信息量(21/21 均为 1) |
| C | 只用 content_list、不要 bbox | 不满足 issue 的"每块含 bbox" |
### Q4 确定性坏输入(E5)的分类
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | **只在 `parse_document` 路径**: ① 本地魔数预检(既非 PDF 也非已知图片魔数 → 本地 `RequestRejectedError`,零 HTTP); ② 500 + `Parsing failed:` 且含已知确定性标记 → `RequestRejectedError`(保留 `status_code=500`)。共享 `_classify_status` 默认行为字节级不变 | 既有 `parse_layout`/`recognize_text` 的行为零变更;未知 500 仍 Transient(安全方向) |
| B | 直接改共享 `_classify_status`,所有 OCR 方法受益 | 改变医院线对坏图的重试/熔断行为,属公共行为变更,应单独走设计 → **另开 issue**(待人类决定) |
| C | 不处理 | 批处理后果见 §6 |
### Q5 长文档超时
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | 不加逐调用 HTTP 超时;文档线用**独立 scope**(如 `OCR_DOC`)指向同一服务 URL,自带 `TIMEOUT_S`/重试/熔断阈值;文档 + 测试钉死"无延迟判慢" | 与现有租约不变式一致 |
| B | `parse_document(timeout_s=…)` 逐调用超时 | `RateLimiter.try_acquire(source_key, est_tokens)`(`ports.py:137`)无 TTL 参数,租约长度是构造期常量,逐调用放大超时会让请求活过租约 → 并发配额被突破 |
## 4. 公共 API(冻结面,人类门审这里)
### 4.1 类型(`types.py`,全部 frozen dataclass)
| 类型 | 字段 | 说明 |
| --- | --- | --- |
| `OcrDocumentElement(OcrLayoutElement)` | 继承 `type/bbox/page_index`;新增 `text: str` | `text` 随 type: `text`/`title` → 纯文本(行内公式保留 `$…$`);`interline_equation` → 裸 LaTeX;`table` → HTML 表体;`image` → `""`;嵌套的非 body 子块(`*_caption`/`*_footnote`)作为**独立元素**输出,type 取子块类型 |
| `OcrDocumentResult` | `elements: list[OcrDocumentElement]`、`page_sizes: list[tuple[float,float]]`、`markdown: str` + 溯源五件 `source_name/usage/latency_ms/call_id/raw` + `call_stats=None` | **独立定义,不继承** `OcrLayoutResult`: 父类末尾 `call_stats` 带默认值,子类无默认字段 `markdown` 会在导入期 `TypeError` |
| `OcrDocumentTransportResult` | `elements`、`page_sizes`、`markdown`、`raw` | transport → client 内部产物 |
约束与理由:
| 点 | 决定 |
| --- | --- |
| 为何继承 | 文档元素"是"版面元素 + 内容;按 bbox 裁剪的下游函数可同时吃两种。代价: dataclass `__eq__` 要求同类,`OcrDocumentElement != OcrLayoutElement`;`OcrLayoutElement` docstring 加警示"追加带默认值字段前须评估子类" |
| 为何不加 `level`/`captions`/`footnotes` | 样本中 21 个 title 全是 level 1(无信息量);caption/footnote 在全部样本里恒为空——为从未见过的路径加公共字段不可验证。嵌套子块独立成元素的方案零新增字段,且服务将来开始关联 caption 时不丢信息 |
| `markdown` | 整篇产物,属 result 不属元素。**含悬空图片引用** `![](images/<sha>.jpg)`(库不外传图片),docstring 注明 |
| 阅读序 | 与服务 `para_blocks` 顺序一致,页内按页序;嵌套子块紧随其父块 |
### 4.2 端口(`ports.py`)
| 端口 | 签名 | 说明 |
| --- | --- | --- |
| `OcrDocumentPort`(新,`runtime_checkable`) | `async def parse_document(self, document: bytes) -> OcrDocumentResult` | 对应 `/parse` 整份文档 |
| `OcrDocumentTransport`(新,`runtime_checkable`) | `async def parse_document(self, *, document: bytes, source: SourceConfig, call_id: str) -> OcrDocumentTransportResult` | **不往 `OcrTransport` 加成员**(会破坏全部现有实现的 `isinstance`);`MonkeyOcrTransport` 同时实现两者 |
`OcrClient.parse_document(document, *, session_id=None, parent_call_id=None, tenant_id=None, meta=None, call_deadline_s=None)`: 签名与 `parse_layout` 对称。入口校验先于 `_CallContext`(同 `_call` 既有顺序): `bytes` 非空、`validate_caller_dimensions`、`ensure_call_deadline`、**transport 须满足 `OcrDocumentTransport`,否则 `TypeError`**(装配错误当场报)。
### 4.3 其它公共面
| 项 | 变更 |
| --- | --- |
| `CallOperation` / `CALL_OPERATIONS`(`types.py:279,285`) | 新增 `"parse_document"`。`CALL_OPERATIONS` 目前**无任何读者**,新增测试钉死 `get_args(CallOperation) == CALL_OPERATIONS` |
| 包根导出 | `OcrDocumentElement`、`OcrDocumentResult` |
| 遥测 | 同一张 `llm_calls`,`operation="parse_document"`;占位仍为 `<ocr:document image_bytes=N>`(**不改字面**: 改名会破坏既有分组与 `test_telemetry.py` 对格式的锁定);response 摘要 `<document elements=N pages=M md_chars=K>`,**永不写入正文**;无新列、无 DDL 变更 |
| 配置 | **无新键**。文档线独立 scope 是部署方式,不是新配置(`.env.example` 加示例段) |
## 5. Transport 行为(`transports/monkey_ocr.py`)
| 关切 | 决策 |
| --- | --- |
| 上传标签 | `_files()` 改为 `_upload_part(data)`: `data.startswith(b"%PDF-")` → `("doc.pdf", data, "application/pdf")`,否则原样 `("image.jpg", data, "image/jpeg")`。`recognize_text`/`parse_layout`/`parse_document` 三处共用。包内文件名前缀由 `image_` 变 `doc_`,而解包靠后缀 `endswith("_middle.json")`,不受影响 |
| 本地预检(仅 document) | 首字节既非 `%PDF-` 也非 JPEG(`FFD8FF`)/PNG/TIFF/BMP/GIF/WebP 魔数 → `RequestRejectedError`(**不带 status_code**,服务未响应),不发 HTTP。拦截"HTML 付费墙页面存成 .pdf"这类批量抓取语料最常见的坏文件 |
| 两段协议 | 与 `parse_layout` 同: POST `/parse` → 校验 `success is True` + `download_url` → GET → ZIP。**共用实现**,只在 ZIP 解析处分叉 |
| ZIP 成员 | 读 `*_middle.json`(取第一个,同 layout);`.md` 按 `middle 名去掉 "_middle.json" + ".md"` 精确配对(不取"第一个 .md");缺失 → `ResultInvalidError`。**不用 `extractall`**,不解出 `images/` |
| 体积上限 | 读取前检查 `ZipInfo.file_size`: middle ≤ 64 MiB、md ≤ 16 MiB(模块常量),超限 → `ResultInvalidError`。仅 document 路径;`parse_layout` 不改(医院线零变更) |
| 页结构 | 复用 `_parse_page_size`;**`page_idx` 必须等于页在列表中的位置**,否则 `ResultInvalidError`(layout 路径的回退枚举会让"page_sizes 按 page_index 索引"契约静默错位,document 路径不继承该宽松) |
| bbox | document 路径只校验"长度 4 的有限数值";**不做**有序性/整数化退化校验(该校验专为 CHS shim 的 `int()` 裁剪,文档消费者不裁剪;200-400 块的文档不应因一个退化框整份失败)。差异写入 docstring |
| 文字提取 | 块内 `lines[].spans[]`: 取 `content`;table 取 `html`;image 的 `image_path` 无文字 → `""`;`inline_equation` 类型 span 包 `$…$`;行内 span 按 `""` 拼、行间按 `"\n"` 拼。text/title span 缺 `content` → `ResultInvalidError`(不静默补空)。**样本全是 1 行 1 span**,多行/多 span/`inline_equation` 路径无真实样本,仅由构造 fixture 测试,文档标"未经实测" |
| 其它页内容 | `discarded_blocks` 忽略(样本中为页眉页脚,span 内容全空);`need_drop=true` 的页不失败,页码记入 `raw["need_drop_pages"]` |
| `raw` | `{"success": True, "download_url": …, "need_drop_pages": [...]}` |
## 6. 错误分类与批处理后果
| 失败 | 分类 | 治理结果 |
| --- | --- | --- |
| 上传前: 非 bytes/空 | `TypeError`/`ValueError` | 统计边界外(同 `_call` 既有) |
| 魔数预检不过 | `RequestRejectedError`(无 status) | 不重试、不换源;仅归还探针,**不计熔断** |
| 500 + `Parsing failed:` + 已知标记 | `RequestRejectedError`(`status_code=500`) | 不重试;熔断**记成功**且不计窗口(`_gate_on_terminal`: `exc.status_code is not None`);不喂选源器 |
| 其余 5xx/429、超时、网络错误 | `TransientError` | 重试 + 计熔断(现状) |
| 401/403 | `SourceDeadError` | 现状 |
| 其余 4xx、`success!=true`(附 200) | `RequestRejectedError` | 现状 |
| ZIP 坏 / middle 或 md 缺失 / 超限 / 结构异常 / `page_idx` 错位 | `ResultInvalidError` | 不重试;熔断记成功不计窗口 |
已知标记(模块常量,小写子串匹配,取 E5 实测): `failed to open stream`、`cannot open empty stream`、`cannot save with zero pages`、`is no pdf`。**不含** `unsupported file type`: 嗅探后它只可能由服务契约变化或我方标签 bug 引起,应响亮失败并计熔断。`MonkeyOcrTransport` 构造参数可覆盖该元组,不新增 `SourceConfig` 字段或环境变量键。
**为什么 Q4 不能不做**(读代码确认的机制链): ① 熔断按 `source_name` 计数(内存 `memory/breaker.py:56-61`;Redis 键 `gate:{scope}:{source}`);② 有效阈值 = `max(配置值, 源并发×2)`(`config.py:503-507`);③ 连续失败通道在"窗口证据不充分"(样本 <`min_calls`=10 或失败率 <0.6,`types.py:1137-1155`)时即开路——每份文档约 15 秒、并发 2,窗口凑不够样本,抑制不生效;④ 一份坏 PDF 重试 `max_attempts` 次即可在低并发配置下开路,冷却内同 scope 的好文档被 `fail_fast` 拒绝。影响范围是**同 scope**: 默认 memory 后端是进程内,只有 Redis 熔断后端加相同 scope 才跨进程(是否波及医院线取决于部署)。⑤ 下游语义: `migrations/chsanalyzer.md:72` 记录 CHSAnalyzer 把库侧"服务不可用"一族按 arq 延期重投且不耗业务失败预算——**若 CHSAnalyzer3 沿用,永久损坏的 PDF 会被无限延期。此点未向下游证实,需人类确认。**
白名单的脆弱性(诚实声明): 标记是 PyMuPDF 的界面文案,随服务版本漂移; 服务把所有异常都包成 `Parsing failed:`,若把磁盘满等真故障也包成同样文案会被误判为坏输入。**误判代价是"该文档失败而非延期"(下游重跑失败集合即可),反向代价是熔断误开**——取前者。
## 7. 长文档与资源(issue 要求 4)
| 结论 | 依据 |
| --- | --- |
| 默认值与熔断**不会把长文档判为慢** | 无延迟型判定(§1 E9);选源器只数成败(`sources.py:61-64`);`StallClock` 只计非生产性等待(`ocr.py:343-344`) |
| 唯一的"慢变失败"路径是 httpx 超时 | `monkey_ocr.py:63-68` → `TransientError`(`__cause__` 为 `TimeoutException`)→ 重试 + 计熔断 |
| 超时是**分阶段**的 | `httpx.Timeout(source.timeout_s)` 四个子超时都取该值: POST 读超时≈服务处理时长上限,GET 下载另有一份 → **一次尝试最坏约 2×`timeout_s`** |
| 租约守卫只比 1× | `config.py:338-345` 要求 `timeout_s ≤ lease_ttl_s`(默认 1500)。OCR 示例 `timeout_s=300` → 2×=600 安全;`timeout_s>750` 时需手动抬 `PGW_LEASE_TTL_S`。守卫改 2× 属全 scope 配置语义变更,**不在本次**,写进文档 |
| 配置口径(写入 `.env.example` 与 README) | 文档线独立 scope + `MAX_CONCURRENCY=2`(满足"并发不超过 2")+ `RETRY__MAX_ATTEMPTS=2` + `timeout_s ≥ 最大页数×1.5s×余量`(共用 GPU 争用余量取 5 倍以上)+ `call_deadline_s` 给单份封顶。有效熔断阈值 `max(配置, 并发×2)=4` > 单份坏文档的重试次数 |
| 重试放大 | POST 成功(服务已算完)而 GET 失败时,重试**重新 POST 整份文档重算**; 超时重试同理,且服务端是否因断连取消计算无证据——假定不取消 |
| 无页数预检 | 库不依赖 PDF 库,发送前不知页数; 300 页文档会占满超时被重投。页数过滤是下游职责 |
| 内存 | `client.get` 整包入内存(含 `images/`);只解必要成员; 声明大小上限见 §5 |
| 服务端磁盘 | 每次调用在服务 `/app/tmp` 与 `/static` 留产物,几百篇约几百 MB 到 GB 级——**告知服务运维**(库不管理) |
| 并发上限的进程边界 | `MAX_CONCURRENCY` 默认进程内; 多 worker 共享上限需 `PGW_LIMITER_BACKEND=redis` 且同 scope |
## 8. 旧版行为审计
| 行为 | 裁决 |
| --- | --- |
| `_files()` 固定 `image.jpg` | **替换**: 以 `%PDF-` 开头 → `doc.pdf`; 其余**保留**(字节级不变) |
| `parse_layout` 收到 PDF | **行为不变(结果逐字相同,E3)**; 仅上传标签与包内前缀变化 |
| `recognize_text` 收到 PDF | **修复**: 静默错误结果(E4)变为整份全文; 文档注明"不承诺 PDF 语义,请用 `parse_document`" |
| `parse_layout` 的 bbox 整数化退化校验 / `page_idx` 回退 / 无 ZIP 体积上限 | **保留不动**(医院线零变更); document 路径各自更严/更宽,差异写入 docstring |
| `_classify_status` 共享分类 | **保留**(默认参数不变) |
| `OcrTransport` 协议 | **保留**,不加成员 |
| `check_health`、重试/选源/限流/熔断/遥测编排 | **保留**: `parse_document` 复用同一 `_call/_run/_attempt` 循环,只扩 `_OcrKind` 与 `_invoke` 分派 |
## 9. 非功能维度
| 维度 | 回答 |
| --- | --- |
| 并发与取消 | 无新共享可变状态; `CancelledError` 路径与 `parse_layout` 逐字一致(退避 sleep/限流等待/两段 HTTP 均可取消,permit 在 `finally` settle+release,探针取消归还)。`call_deadline_s` 到期会取消在飞请求,服务端是否继续计算未知 |
| 降级方向 | 限流/熔断后端故障 → 报错不放行(铁律,复用既有); 遥测写失败 → warning 降级 |
| 幂等与重复 | 只读幂等,重放安全; 代价见 §7"重试放大" |
| 持久化与原子性 | 库内无持久化; ZIP 解包在内存(`BytesIO`),不落盘 |
| 零业务假设 | 无医学/论文词汇进入库代码与 fixtures; `type` 仍为开放字符串 |
## 10. 测试策略
fixtures 一律**合成**(`tests/unit/test_monkey_ocr.py` 的 `_zip_bytes`/`_page` 风格上扩一个 document builder),**不提交任何真实 ZIP**(第三方内容许可不明; 医疗数据不入库,同 M3 设计 §11)。
| 层 | 内容(每条先红后绿) |
| --- | --- |
| unit/transport | 嗅探: PDF 头 → `doc.pdf`/`application/pdf`,非 PDF 逐字 `image.jpg`/`image/jpeg`,**三个方法各钉一次**(`test_multipart_shape` 现有断言保持成立); 文档解析: title/text(含 `$X$`)/interline_equation/带 nested `table_body` 的 table/带 `image_body` 的 image 的 text 取值; 垃圾 `content_list.json` 与 `images/x.jpg` 并存时输出与其无关; nested caption/footnote 成独立元素(标未经实测); `inline_equation` 包 `$`; 多行/多 span 拼接; `markdown` 精确配对与缺失; 体积上限; `page_idx` 错位; 退化 bbox 不失败; `need_drop` 记录; text span 缺 `content` → ResultInvalid |
| unit/分类 | 魔数预检(HTML 页面、空串、未知魔数); 四个已知标记 → `RequestRejectedError(status_code=500)`; 未知 500 仍 Transient; `unsupported file type` 仍 Transient; 默认 `_classify_status` 行为不变(现有 `test_http_status` 保持) |
| unit/client | 桩 transport 走完整治理循环; `isinstance` 缺 `OcrDocumentTransport` → `TypeError`; **无延迟判慢**: 在成功尝试内推进远大于 `timeout_s` 的时钟,断言成功、熔断 CLOSED、`fails==0`; 对照: 超时 Transient → 计失败 → 重试; ResultInvalid/本地预检不计熔断窗口; 取消穿透且探针归还; 遥测行 `operation="parse_document"`、占位字面量、摘要不含正文; `_summarize` 三类结果各一条(防 `hasattr` 回退); 终态行 |
| unit/契约 | `get_args(CallOperation) == CALL_OPERATIONS`; `test_ports.py` 新增 Protocol 的 `runtime_checkable` 用例,并断言 `OcrTransport` 成员集合**未变** |
| integration(`slow`,opt-in,串行) | 手写最小合法 PDF(无第三方内容)打真实服务: `parse_document` 返回 ≥1 页、元素均有 `text` 类型; 同一 PDF 的 `recognize_text` 不再返回 E4 的幻觉文本; 损坏 PDF → `RequestRejectedError` 且熔断仍 CLOSED。成败取决于外部服务当下状态,按 CLAUDE.md §4.6 标 `slow`,不进日常套件 |
## 11. 落地时需同步的位置(grep 全引用面所得)
| 类别 | 位置 |
| --- | --- |
| 代码 | `types.py`(Literal/常量/新类型)、`ports.py`、`ocr.py`(`_OcrKind`/`_AttemptOutcome`/`_invoke`/`_emit`/`_summarize` 签名)、`transports/monkey_ocr.py`、`__init__.py`(导出 + `__version__`)、`pyproject.toml` |
| 校验工具 | `tools/check_wiki_alignment.py:37-46`(`_SIGNATURE_TARGETS` 加 `OcrClient.parse_document`) |
| wiki 注册 | `research-wiki/schemas/llm-calls.md`(operation 取值)、`metrics/ocr-call-success.md`、`metrics/call-telemetry-coverage.md`(无推理入口列表) |
| 文档 | `README.md`(方法数、能力表、安装版本约束)、`CHANGELOG.md`、`.env.example`(文档线 scope 示例)、`ARCHITECTURE.md` §5.1/§7.10/D9 注记、M3 设计 §4 勘误(指向本设计 E3/E4)、`migrations/chsanalyzer.md`(CHSAnalyzer3 新消费路径) |
| 测试 | `test_telemetry.py` 的 operation 参数化、`test_ports.py`、`test_monkey_ocr.py`、`test_ocr_client.py`、`tests/integration/test_monkey_ocr_live.py` |
| 无需迁移 | `llm_calls.operation` 列为 `TEXT` 无 CHECK(`telemetry/schema.py`),新值不需 DDL; 外部按 operation 分组的看板会自然多一组 |
CLAUDE.md §4.4.1 发布流程按其清单执行(README 在构建前改完; 1.3.11 与历史把新增公共方法放 patch 的惯例一致)。
## 12. 评审与待人类拍板
**已做**: Claude 自审; 一轮独立 subagent(全新上下文、只读)对 Q1-Q5 与遥测/引用面逐项论证。**采纳其修正**: 弃初稿"content_list+middle 对位"(Q3,以 `\$` 污染 23/134 为据)、砍 `level/captions/footnotes`(Q1)、`OcrDocumentTransport` 另起 Protocol 而非扩 `OcrTransport`(§4.2)、document 路径的 `page_idx` 严格与 bbox 宽松(§5)、确定性 500 只做新路径窄化(Q4)、ZIP 体积上限、`_summarize` 改法。**更正我方事实**: 初稿称"结果包无 `images/`",实为含图文档才有(E6)。**Codex 独立审**: 见文末"审查记录"。
**需要人类决定**:
| # | 决策 | 推荐 |
| --- | --- | --- |
| H1 | §4 公共面(新方法 + 新 Protocol + 继承式元素、`text` 唯一新增字段、嵌套子块独立成元素) | 批准 |
| H2 | 确定性坏输入分类: 仅新路径窄化(本设计),共享分类**另开 issue** 供医院线评估 | 同意,并授权我建该 issue |
| H3 | CHSAnalyzer3 是否把"服务不可用"一族按延期重投处理(§6 ⑤)——决定 `RequestRejectedError` 路径是否必要地"关键" | 请确认; 不影响实现,影响下游使用指引 |
| H4 | 实现完成后的发布(合并 main、tag、构建、上传 registry、建 Release、关闭 #34)**逐步等你明确指令**,不随实现自动执行 | 同意 |
## 审查记录
(待补: Codex 审查结论与逐条核验。)