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.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
---
|
||||
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 不属元素。**含悬空图片引用** ``(库不外传图片),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 审查结论与逐条核验。)
|
||||
Reference in New Issue
Block a user