Files
PolyGateway/research-wiki/designs/2026-10-21-issue34-ocr-document-design.md
T
iomgaa fb67b68440 docs: fold independent review findings into OCR document design
Adds live-service and server-source evidence, structure contract, exception and resource boundaries, corrected breaker analysis, and the Codex review record.
2026-10-21 22:16:50 -04:00

40 KiB
Raw Blame History

type, node_id, title, date
type node_id title date
design design:2026-10-21-issue34-ocr-document-design OCR 整份文档解析设计(#34) 2026-10-21

#34 OCR 整份文档解析设计

状态: 待人类审批(公共 API + 端口新增,强制人类门)。已完成 Claude 自审与一轮独立 subagent 论证(§12);Codex 一轮审查已完成并逐条处置(§12 审查记录)。 基线: 25a90eb(1.3.10);目标版本 1.3.11;分支 fix/issue34-ocr-document。 需求: #34(CHSAnalyzer3 把几百篇医学论文 PDF 整份转成带页码正文与表格)。 修订对象: M3 OCR 设计 §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/.md 逐字相同(只有 message 与包内文件名前缀不同)——文本型 2 页 PDF 与无文字层的 2 页位图 PDF(扫描件模拟,pdftotext 字符数 0)两种均验证; 旋转页、带批注页未测;PNG/TIFF/BMP 字节标 image.jpg 亦成功(服务按内容解码);前缀 200 字节垃圾的 PDF 以 doc.pdf 上传仍被解析 问题一在 /parse 上目前未观察到结果差异,但不是保证(E10);非 PDF 沿用 image.jpg 才是零回归;前缀垃圾的 PDF 服务可解析,嗅探须容忍
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 的超时被算成失败
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 不加字段;嵌套子块仅作最小防御
E13 /ocr/text 对无法解码的输入返回 success:true + 幻觉文本: 乱码字节(GARBAGE-NOT-AN-IMAGE)得到与 E4 完全相同的 "The image contains a single character: 1"——即 E4 的本质是"服务把 PDF 当作无法解码的图"。另据源码,该端点内部异常会返回 HTTP 200 + success:false + content:""(api/main.py:622-629),而 recognize_text 只校验 content 为 str、不看 success(monkey_ocr.py:240-247)。后者未经线上触发,仅源码推断 标签修正只能消除"PDF 被当图"这一类; 对无法解码输入的静默错误是服务行为,库侧要治需另行设计(§12 H2)
E14 服务并发模型: /parse 推理在 sync-only 后端下持有全局 asyncio.Lock(api/main.py:183-189);supports_async 取决于 chat 后端是否有 async_batch_inference,出厂默认 lmdeploy 为 sync-only(model_configs.yaml),线上实际 backend 未知(启动日志有 async-capable/sync-only 一行) 若 sync-only: 每实例 /parse 全局串行,我们的长文档会队头阻塞医院线; timeout_s 须覆盖"排队+处理"

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 按内容嗅探,判定顺序固定: ① 起始为已知图片魔数(JPEG/PNG/TIFF/BMP/GIF/WebP)→ 图片;② 否则前 1024 字节内含 %PDF- → PDF(Adobe 阅读器的头部容忍惯例,E3 实测服务亦可解析);③ 否则未知。PDF → doc.pdf/application/pdf,图片与未知一律沿用 image.jpg/image/jpeg。单点 _upload_part(),三个方法共用 图片魔数先判,含 XMP 元数据的图片不会因内嵌 %PDF- 字样被误判;非 PDF 路径字节级不变;顺带修 E4
B 调用方传 media_type/filename(issue #34 原文提议) 声明与内容不符 → E2 的 500;若放在旧两方法上,仍走共享分类 → Transient → 重试并计熔断: 笔误变熔断事件。库本就能从内容判定,声明是冗余信息; 唯一得益场景(前缀垃圾 PDF)已被 A 的 ② 覆盖。这是对 issue 提议的有意替换,列入 H5 由人类批准
C 嗅探全部图片格式并精确标注(png→image.png) 零收益;webp/gif 等无对应扩展名反而要拒绝,把现在能成功的路径(E3)变成失败

三个方法对同一输入的结局(Codex 审查要求显式化):

输入 recognize_text parse_layout parse_document
正常 PDF doc.pdf,整份全文(E4 修复) doc.pdf(上传分支变更,见 §8) doc.pdf,完整文档结果
前缀垃圾的 PDF 同上 同上 同上
已知图片 / 其它可解码图片 image.jpg(不变) image.jpg(不变) image.jpg,1 页结果
无法识别的字节(HTML 页面、乱码、空) image.jpg(不变,服务给幻觉文本 E13) image.jpg(不变,服务 500) 本地 RequestRejectedError,不发 HTTP

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),按 §3 Q2 的固定顺序嗅探: PDF → ("doc.pdf", data, "application/pdf"),其余 → 原样 ("image.jpg", data, "image/jpeg")。三个方法共用。包内文件名前缀由 image_ 变 doc_,解包靠后缀 endswith("_middle.json")(monkey_ocr.py:179),不受影响
本地预检(仅 document) 嗅探结果为"未知"(HTML 付费墙页面、乱码、空串)→ RequestRejectedError(不带 status_code,服务未响应),不发 HTTP。已知图片魔数: JPEG FFD8FF、PNG、TIFF(II*\0/MM\0*)、BMP(BM)、GIF、WebP(RIFF…WEBP);魔数表之外的可解码图片格式在 document 路径会被拒,需要时由调用方走 parse_layout
两段协议 与 parse_layout 同: POST /parse → 校验 success is True + download_url → GET → ZIP。document 路径的 GET 改流式读取并累计字节,整包上限 256 MiB(模块常量),超限 → ResultInvalidError;HTTP 状态错误须先 aread() 再 raise_for_status(),保持"响应体摘要随所有分支"的 issue #10 口径
解析卸载 ZIP 解包 + JSON 解析 + 结构校验是 bytes→结果的纯函数,在 asyncio.to_thread 中执行: 同步解析大对象会占住事件循环,使取消与 call_deadline_s(基于 asyncio.timeout,deadline.py:69-79)无法及时生效。取消后线程自然收尾、结果丢弃。parse_layout 不改
外部数据异常边界 纯函数内统一把外部字节引起的已知异常转 ResultInvalidError: 函数级 ValueError(含 JSONDecodeError/UnicodeDecodeError)、TypeError、KeyError、OverflowError(巨大整数转 float,实测 float(10**400) 抛它)、RecursionError(深嵌套 JSON)、zipfile.BadZipFile/LargeZipFile;仅包在 archive.open/read 外的窄 try: RuntimeError(标准库对加密成员抛它)、NotImplementedError(未知压缩方法)、zlib.error、EOFError。取消(CancelledError)不在其内,原样穿透。理由: ocr.py:303-321 的 _call 只接 PolyGatewayError,漏出的原生异常既不写终态遥测行,又会让持有的半开探针不被归还
ZIP 成员与体积 读 *_middle.json(取第一个,同 layout);.md 按 middle 名去掉 "_middle.json" + ".md" 精确配对(不取"第一个 .md");缺失 → ResultInvalidError。读取前检查 ZipInfo.file_size: middle ≤ 64 MiB、md ≤ 16 MiB,超限 → ResultInvalidError。不用 extractall,不解出 images/;成员数由整包上限间接约束。仅 document 路径;parse_layout 不改(医院线零变更)
文字提取 块内 lines[].spans[]: 取 content;inline_equation 类型 span 包 $…$(上游惯例,样本中从未出现);行内 span 按 "" 拼、行间按 "\n" 拼。样本全是 1 行 1 span,多行/多 span 路径无真实样本,仅构造 fixture 测试,文档标"未经实测"
其它页内容 discarded_blocks 忽略(E12: 服务端已丢弃脚注/页眉页脚/页码,span 内容全空,库无物可取);need_drop 不读取、不失败(该流水线硬编码为 False)
嵌套子块 默认部署不产生(E12);仅作最小防御: 非 *_body 的嵌套子块若有文字,作为独立元素紧随父块输出,不静默丢弃。该路径无真实样本,仅构造 fixture 测试
raw {"success": True, "download_url": …},与 parse_layout 同形

5.1 结构契约(缺失 / 空值 / 类型错误 / 未知类型逐项裁决)

架构要求严格区分合法"无内容"与调用失败(ARCHITECTURE.md:427,672)。下表是实现与测试的唯一依据。

情形 处理
pdf_info 缺失或非 list ResultInvalidError
pdf_info == [](零页) ResultInvalidError: 成功解析的文档至少 1 页(与 layout 路径"elements 空合法"不同,差异写入 docstring)
页非 dict / page_size 非 2 个正有限数 ResultInvalidError(复用 _parse_page_size)
page_idx 非 int、为 bool、为负、或 ≠ 页在列表中的位置 ResultInvalidError(layout 路径的"回退枚举"会使 page_sizes 按 page_index 索引的契约静默错位)
para_blocks 缺失 合法空页(空白页 / 无可识别块);非 list → ResultInvalidError
块非 dict、type 缺失/非 str/空白 ResultInvalidError
bbox 须为长度 4 的有限数值且 x1 ≤ x2、y1 ≤ y2(允许零宽/零高,不做整数化退化校验——那条专为 CHS shim 的 int() 裁剪);反序/非有限 → ResultInvalidError。坐标系与 page_sizes 同为服务原生坐标,页码从 0 起;越界框不检查、不裁剪(图内语义归调用方,ARCHITECTURE.md:250)
含 lines 的块(text/title/interline_equation/未知类型) lines 须 list,每行 dict 且 spans 为 list,每 span 为 dict 且 content 为 str;违反 → ResultInvalidError;lines == [] → text=""
table 嵌套 table_body 的 span 取 html,无则取 latex(上游有该分支);二者皆无 → ResultInvalidError(表格无内容不静默为空串)
image 嵌套 image_body → text=""
未知类型且既无 lines 也无 blocks text="",type 原样保留(开放字符串)
.md 成员缺失 → ResultInvalidError;按 utf-8-sig 解码(吞 BOM),失败 → ResultInvalidError;空串合法

6. 错误分类与批处理后果

失败 分类 治理结果
上传前: 非 bytes/空 TypeError/ValueError 统计边界外(同 _call 既有)
嗅探为"未知"(§5) RequestRejectedError(无 status) 不重试、不换源;仅归还探针,不计熔断
500 + Parsing failed: + 已知标记 RequestRejectedError(status_code=500) 不重试;熔断记成功且不计窗口(_gate_on_terminal: exc.status_code is not None);不喂选源器
429 TransientError(status_code=429) 照扣重试预算、喂选源器失败; 熔断两通道均不计(memory/breaker.py:235-244: rate_limited 是背压不是故障)
其余 5xx、超时、网络错误 TransientError 重试 + 计熔断(现状)
CallDeadlineExceeded(整体期限到期) / 外部取消 专用异常 / CancelledError 不是源故障: 不计熔断; 在飞探针归还; 终态遥测行照写(ocr.py:303-321)。期限不会被混入 TransientError
401/403 SourceDeadError 现状
其余 4xx、success!=true(附 200) RequestRejectedError 现状
ZIP 坏 / 加密或未知压缩成员 / middle 或 md 缺失 / 超限 / 结构异常(§5.1)/ page_idx 错位 / 零页 ResultInvalidError 不重试;熔断记成功不计窗口(_gate_on_terminal: ocr.py:493-499)

已知标记(模块常量,小写子串匹配,取 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 键 pgw:gate:{scope}:{source},backends/redis/breaker.py:308-311);② 有效阈值 = max(配置值, 源并发×2)(config.py:503-508);③ 连续失败通道仅在窗口无充分健康证据时开路——"充分健康证据"= 窗口样本 ≥ min_calls(默认 10)且失败率 < fail_rate(默认 0.6)(memory/breaker.py:84-90、开路判据 :252),故样本不足 或 失败率 ≥ 0.6 时连败即开路。每份文档约 15 秒、并发 2,窗口凑不够 10 个样本,抑制不生效;④ 一份坏 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)→ 重试 + 计熔断; ② 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 > 单份坏文档的重试次数
重试放大 POST 成功(服务已算完)而 GET 失败时,重试重新 POST 整份文档重算; 超时重试同理,且服务端是否因断连取消计算无证据——假定不取消
无页数预检 库不依赖 PDF 库,发送前不知页数; 300 页文档会占满超时被重投。页数过滤是下游职责
内存 document 路径 GET 改流式并累计字节,整包上限 256 MiB(§5);只解必要成员,成员再受声明大小上限约束。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、错峰运行、页数过滤在调用方
服务限制(告知下游) 脚注/页眉页脚/页码在服务端即被丢弃(E12): 论文表格脚注的显著性与缩写说明不会出现在结果里; markdown/elements 均无此内容。库不能也不应补偿

8. 旧版行为审计

行为 裁决
_files() 固定 image.jpg 替换: 嗅探为 PDF → doc.pdf; 其余保留(字节级不变)
parse_layout 收到 PDF 有意改变上传分支(ImageDataset → PymuDocDataset,E10)。差分证据: 文本型与位图型 2 页 PDF 结果逐字相同(E3); 旋转页、带批注页未测,不构成保证。该路径无已知消费者(医院线只传图片,CHSAnalyzer3 走新方法),故列 H6 由人类批准,而不是声称"零风险"
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,探针取消归还); 新增的 ZIP 解析在 to_thread 中,取消时立即返回(线程自然收尾、结果丢弃),不会像同步解析那样占住事件循环使取消/期限失效。call_deadline_s 到期会取消在飞请求,服务端是否继续计算未知
降级方向 准入阶段(选源/限流/熔断)后端故障 → 报错不放行(铁律,复用既有 SourceAdmission); 已发出请求之后的记账写回(_record_quietly,ocr.py:511-517)与遥测写失败 → 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、前缀垃圾的 PDF、起始为图片魔数但内含 %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 精确配对与缺失; 体积上限; §5.1 契约表逐行一条用例(零页、page_idx 为 bool/负/错位、para_blocks 缺失=空页、非 list、块无 type、反序/非有限 bbox、零宽 bbox 合法、lines 各层类型错误、table 无 html/latex、未知类型、.md 缺失/非法编码/BOM/空串); discarded_blocks 不进结果
unit/资源与异常边界 加密成员、未知压缩方法、10**400 整数、深嵌套 JSON、CRC 损坏 → 全部 ResultInvalidError(不漏出原生异常),并断言经 OcrClient 后终态遥测行已写、半开探针已归还; 整包流式累计超限(大图片包 + 小 middle/md)→ ResultInvalid; 声明大小超限成员 → ResultInvalid; 解析期间取消 → 立即 CancelledError 穿透(用阻塞式解析桩验证事件循环未被占住)
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(含"熔断已有失败计数"与半开探针两种前置状态,不只测初始 CLOSED); 对照: 超时 Transient → 计失败 → 重试; 持续慢速下载(每个分块都在 read 超时内、总时长超过 call_deadline_s)→ CallDeadlineExceeded 而非 Transient,且不计熔断; 429 → 照扣重试预算但熔断窗口不变; ResultInvalid/本地预检不计熔断窗口; 取消穿透且探针归还; 遥测行 operation="parse_document"、占位字面量、摘要不含正文; _summarize 三类结果各一条(新结果必须显式区分,防止静默套用 layout 摘要而漏记 md_chars); 终态行; 调用方维度(tenant_id/meta)与 reasoning_applies=False
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 与遥测/引用面逐项论证,并核对本机 MonkeyOCR 源码(E10-E14,关键行已由我对源码与线上各复核一次)。采纳其修正: 弃初稿"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 供评估: (a) 共享 _classify_status 对确定性坏输入 500 仍归 Transient; (b) recognize_text 不校验 success(服务内部异常时返回 200+success:false+空文本,被当"合法无文字"且熔断记成功,E13,源码推断未线上复现) 同意,并授权我建这两个 issue
H5 需求替换: issue #34 原文提议 parse_document(data, *, filename 或 media_type) 由调用方声明类型; 本设计改为库按内容嗅探、不提供声明参数(理由见 §3 Q2: 冗余信息、笔误会变熔断事件、前缀垃圾 PDF 已被嗅探覆盖) 批准替换; 若你更想要显式参数,代价是新增公共参数 + 与嗅探结果不符时的拒绝规则
H6 有意改变既有方法的上传分支: parse_layout(PDF) 与 recognize_text(PDF) 的 PDF 将走 PymuDocDataset/doc.pdf(前者结果在已测样本上逐字相同,旋转/批注页未测;后者是修复 E4) 批准
H3 CHSAnalyzer3 是否把"服务不可用"一族按延期重投处理(§6 ⑤)——决定 RequestRejectedError 路径是否必要地"关键" 请确认; 不影响实现,影响下游使用指引
H4 实现完成后的发布(合并 main、tag、构建、上传 registry、建 Release、关闭 #34)逐步等你明确指令,不随实现自动执行 同意

审查记录

Codex 一轮独立只读审查(上游无法读取 Gitea issue,需求判断基于设计转述)。结论: 否,先修正。逐条核验与处置:

# Codex 意见 核验 处置
1 阻断 "2×timeout_s"不是总耗时上界,租约安全结论失效 属实: httpcore 逐次读计时,GET 持续分块可超任意多个 timeout_s; 租约/探针租约到期语义见 memory/limiter.py:98-103、memory/breaker.py:161-163 §7 删除"2×即安全",改为硬保证只来自 call_deadline_s ≤ lease_ttl_s; 测试加慢速持续下载
2 高 调用方声明类型被自动嗅探替换,理由与 Q4 自洽性不足; 前缀垃圾 PDF 描述矛盾; "1024 字节"依据未核 部分属实: 替换确属需求变更,须显式批准; 前缀垃圾 PDF 服务实测可解析,故改嗅探为"图片魔数优先 → 前 1KB 内 %PDF-",该场景不再被拒; 1024 字节来自 Adobe 阅读器的头部容忍惯例,不是 ISO 32000 正文,已改述为兼容性描述 §3 Q2 重写 + 三方法结局表; 新增 H5
3 高 parse_layout(PDF)"零风险/不变"与 E10 矛盾 属实: 分支确实变了。补做位图型 PDF 差分(逐字相同); 旋转/批注页仍未测 §8 改述"有意改变",新增 H6
4 高 成员大小限制不限整包内存; 同步解析使取消/期限失效 属实(httpx/_client.py:1635-1637 非流式先 aread(); deadline.py 基于 asyncio.timeout 无法打断同步代码) §5: document 路径流式 + 整包 256 MiB 上限 + to_thread 卸载; 测试加解析期间取消
5 中 ZIP/结构异常边界缺失,加密成员/未知压缩/巨大整数会漏出原生异常 属实: 实测 float(10**400) 抛 OverflowError,不在既有异常元组内; 漏出后 _call 不写终态行、探针不归还 §5 明确异常转换边界,§10 加专项测试
6 中 逐块文字/空结果/未知类型/编码契约不完整 属实 新增 §5.1 结构契约表,§10 逐行一条用例
7 中 bbox 放宽与"继承使裁剪函数可通吃"自相矛盾 属实 改为 x1≤x2 且 y1≤y2(允许零宽,去掉整数化退化),坐标系/页码约定写明
8 中 429 记账写错; 窗口条件写反; 遗漏 call_deadline_s 全部属实(memory/breaker.py:84-90,235-244): "失败率<0.6"是健康证据而非"证据不足" §6/§7 订正,增 429 与期限两行
9 低 _summarize 风险描述不准 属实: 实际是漏记 md_chars 措辞订正
引用核对 api/main.py:435-441 行号偏移(分派在 :164-170); config.py:503-507 应为 :508; Redis 键缺 pgw: 前缀 属实 已订正

未采纳: 无(九条均成立,部分处置方式有调整)。订正后未再送 Codex 复审——修订量大,建议人类审阅时一并关注 §3 Q2、§5、§5.1、§7。