Files
PolyGateway/research-wiki/plans/2026-10-21-issue34-ocr-document.md

32 KiB

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

#34 OCR 整份文档解析实现计划

实现已获批准的设计 2026-10-21-issue34-ocr-document-design(下称「设计」,章节号引自它)。 分支 fix/issue34-ocr-document;基线 25a90eb(1.3.10);目标 1.3.11。发布(合并/tag/上传 registry/建 Release/关 #34)不在本计划内,等人类逐步指令(设计 H4)。 保真校验: 本计划不涉及参考实现迁移,保真校验不适用。

目标: 让 OCR 客户端支持整份 PDF 文档解析,返回带页码/bbox/类型/文字的逐块结果与整篇 Markdown,并修正上传类型标签与确定性坏输入的分类。

方案概述: 新增 OcrClient.parse_document 与独立 Protocol OcrDocumentTransport;transport 按内容嗅探 PDF 并设置上传标签;结果只从 _middle.json + 配对 .md 解析(不读 _content_list.json);新路径对一组已知的确定性 500 改归「请求被拒」。parse_layout/recognize_text 的签名与返回类型不动。

技术: Python 3.12、httpx(流式 GET)、asyncio.to_thread、标准库 zipfile/json;零新依赖。

0. 全局约束(每个任务都适用)

约束 内容
先红后绿 每个任务先写测试并亲眼看到因目标行为缺失而失败,红灯输出存 scratchpad/<任务>-red.log,再实现到绿。测试结果门要求的证据,最终报告逐条引用
提交门 .claude/scripts/hooks/pre-commit-guard.sh 在提交时跑全套 pytest tests/ 并拒绝「本次改动触及的函数圈复杂度 ≥ C」(radon ≥ 11)。故所有新函数须拆小(见 T3 的分解),提交必须调用 commit skill、不带 AI 签名
命令 一律 conda run -n PolyGateway ...;长命令输出重定向到文件并直接取退出码,不接管道(CLAUDE.md §3 执行模式)
复杂度 新函数的复杂度在实现前只能标「预计」,每次提交前对改动模块跑 radon cc <文件> -n C,有输出即拆分;不得提前宣称已通过
层次 import-linter 契约:ports/types/errors 最内层;transports/ 只实现端口、不依赖 middleware/;每个任务结束跑 make lint
中文 模块/类/方法中文 docstring;注释解释「为什么」;日志用 loguru,禁 print
不顺手清理 只动本计划列出的文件与函数;parse_layout 的行为(bbox 整数化退化校验、page_idx 回退、无体积上限)保持原样
基线 动手前 OCR 相关单测 257 passed(test_monkey_ocr/test_ocr_client/test_ports/test_types/test_package)

1. 文件结构

文件 动作 职责
src/polygateway/types.py 改 CallOperation+CALL_OPERATIONS 增 "parse_document";新增 OcrDocumentElement/OcrDocumentResult/OcrDocumentTransportResult;OcrLayoutElement docstring 加继承警示
src/polygateway/ports.py 改 新增 OcrDocumentPort、OcrDocumentTransport(不动 OcrTransport 成员)
src/polygateway/transports/_monkey_document.py 新 结果包解析纯函数(无 HTTP、无治理):parse_document_package;承接并原样迁入 _finite_number/_parse_page_size(避免与 monkey_ocr.py 循环导入;monkey_ocr.py 反向导入它们)
src/polygateway/transports/monkey_ocr.py 改 _sniff_media/_upload_part/_files;_classify_status+_translate_http_errors 增 rejected_markers;MonkeyOcrTransport.parse_document、流式下载、第一段协议抽共用
src/polygateway/ocr.py 改 OcrClient.parse_document;_OcrKind 增 "document";私有方法形参 image→payload;_invoke/_summarize 分派
src/polygateway/__init__.py 改 导出 OcrDocumentElement/OcrDocumentResult(版本号放 T9)
tools/check_wiki_alignment.py 改 _SIGNATURE_TARGETS 加 OcrClient.parse_document
research-wiki/schemas/llm-calls.md、metrics/ocr-call-success.md、metrics/call-telemetry-coverage.md 改 operation 取值与摘要格式
tests/unit/{test_types,test_ports,test_package,test_monkey_ocr,test_ocr_client,test_telemetry}.py 改 见各任务
research-wiki/{index.md,log.md,graph/edges.json} 改(自动生成) T7 的 make wiki 产物,随该任务提交
tests/unit/test_monkey_document.py 新 结果包解析的契约用例(设计 §5.1 逐行一条)
tests/integration/test_monkey_ocr_document_live.py 新 slow 活体用例(T8)。不改既有 test_monkey_ocr_live.py: 它带模块级 skipif(依赖图片语料)且用例未标 slow,默认套件会收集它(实测 6 项),混入新用例会改变提交门的行为
CHANGELOG.md、README.md、.env.example、pyproject.toml、research-wiki/{ARCHITECTURE.md,designs/2026-07-21-m3-ocr-design.md,migrations/chsanalyzer.md} 改 T9

2. 跨任务关键接口(执行时以此为准)

# types.py
@dataclass(frozen=True)
class OcrDocumentElement(OcrLayoutElement):
    text: str   # 随 type: text/title→纯文本; interline_equation→裸 LaTeX; table→HTML; image→""

@dataclass(frozen=True)
class OcrDocumentTransportResult:
    elements: list[OcrDocumentElement]
    page_sizes: list[tuple[float, float]]
    markdown: str
    raw: dict[str, Any]

@dataclass(frozen=True)
class OcrDocumentResult:   # 独立定义,不继承 OcrLayoutResult(父类 call_stats 带默认值)
    elements: list[OcrDocumentElement]
    page_sizes: list[tuple[float, float]]
    markdown: str
    source_name: str
    usage: Usage
    latency_ms: int
    call_id: str
    raw: dict[str, Any]
    call_stats: CallStats | None = None

# ports.py
@runtime_checkable
class OcrDocumentPort(Protocol):
    async def parse_document(self, document: bytes) -> OcrDocumentResult: ...

@runtime_checkable
class OcrDocumentTransport(Protocol):
    async def parse_document(
        self, *, document: bytes, source: SourceConfig, call_id: str
    ) -> OcrDocumentTransportResult: ...

# transports/_monkey_document.py  (全部无 I/O,可在线程中运行)
MAX_MIDDLE_BYTES = 64 * 1024 * 1024
MAX_MARKDOWN_BYTES = 16 * 1024 * 1024
def parse_document_package(
    zip_bytes: bytes,
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]], str]: ...  # 失败一律 ResultInvalidError

# transports/monkey_ocr.py
_MediaKind = Literal["pdf", "image", "unknown"]
def _sniff_media(data: bytes) -> _MediaKind: ...
def _upload_part(data: bytes) -> tuple[str, bytes, str]: ...
_MAX_PACKAGE_BYTES = 256 * 1024 * 1024
_DEFAULT_REJECTED_MARKERS = (   # 小写子串;取设计 §6 实测
    "failed to open stream", "cannot open empty stream",
    "cannot save with zero pages", "is no pdf",
)
def _classify_status(exc, source_name, operation, rejected_markers: tuple[str, ...] = ()): ...

# ocr.py
async def parse_document(
    self, document: bytes, *, session_id: str | None = None, parent_call_id: str | None = None,
    tenant_id: str | None = None, meta: Mapping[str, Any] | None = None,
    call_deadline_s: float | None = None,
) -> OcrDocumentResult: ...

3. 任务

T1 内核类型与端口 — [ ] 一次提交

  • 文件: types.py、ports.py、__init__.py(仅导出)、tests/unit/test_types.py、tests/unit/test_ports.py、tests/unit/test_package.py(其显式导出名单加两个新名)。
  • 行为: 按 §2 增加三个数据类与两个 Protocol;CallOperation/CALL_OPERATIONS 增 "parse_document";OcrLayoutElement docstring 加「追加带默认值字段前须评估子类 OcrDocumentElement(无默认字段会在导入期 TypeError)」;OcrDocumentResult 不继承 OcrLayoutResult,docstring 写明原因;OcrDocumentResult.markdown 的 docstring 写明「含悬空 ![](images/<sha>.jpg) 引用——库不外传图片」,OcrDocumentElement docstring 写明 text 随 type 的含义(设计 §4.1)。
  • 测试(先红):
    • test_document_element_is_layout_element: isinstance(el, OcrLayoutElement) 为真,且 OcrDocumentElement(type="t",bbox=(0,0,1,1),page_index=0,text="x") != OcrLayoutElement(type="t",bbox=(0,0,1,1),page_index=0)(dataclass __eq__ 要求同类,钉死该已知语义)。
    • test_document_types_frozen: 三个新类型赋值抛 FrozenInstanceError。
    • test_call_operations_matches_literal: get_args(CallOperation) == CALL_OPERATIONS(该常量此前无任何读者)。
    • test_ocr_document_ports_runtime_checkable: 合格/缺方法的桩各一;test_ocr_transport_members_unchanged: OcrTransport 的成员集合恰为 {recognize_text, parse_layout, check_health}(防止有人把新方法塞进旧 Protocol);test_ports.py:190-196 的参数化补 (OcrDocumentTransport, "parse_document") 断言无 reasoning 档。
    • test_package.py 已有导出一致性用例须覆盖两个新导出名。
  • 验证: conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_ports.py tests/unit/test_package.py -q --no-cov > out.log 2>&1; echo $? → 0;make lint 通过。
  • 提交: feat: add OCR document types and ports。

T2 上传类型嗅探 — [ ] 一次提交

  • 文件: transports/monkey_ocr.py、tests/unit/test_monkey_ocr.py。
  • 行为(设计 §3 Q2、§5): _sniff_media 判定顺序固定——① 起始为图片魔数(JPEG \xff\xd8\xff、PNG \x89PNG\r\n\x1a\n、TIFF II*\x00/MM\x00*、BMP BM、GIF GIF87a/GIF89a、WebP RIFF????WEBP)→ "image";② 否则前 1024 字节内含 %PDF- → "pdf";③ 否则 "unknown"。_upload_part: pdf → ("doc.pdf", data, "application/pdf"),其余(含 unknown)原样 ("image.jpg", data, "image/jpeg")。MonkeyOcrTransport._files 改为 {"file": _upload_part(data)},recognize_text/parse_layout 无需改动调用点。
  • 测试(先红): 参数化 _sniff_media(纯 PDF、前缀 200 字节垃圾的 PDF、JPEG/PNG/TIFF 两种字节序/BMP/GIF/WebP、起始为 JPEG 魔数但 1KB 内含 %PDF- 字样的图片须判 image、HTML、乱码、空串);test_upload_label_per_method: recognize_text/parse_layout 对 PDF 字节发出 filename="doc.pdf" 与 Content-Type: application/pdf,对非 PDF 逐字 filename="image.jpg"(现有 test_multipart_shape 保持通过)。
  • 验证: pytest tests/unit/test_monkey_ocr.py -q --no-cov 全绿。
  • 提交: fix: label PDF uploads by content instead of a fixed image name。

T3 结果包解析纯函数 — [ ] 一次提交(本计划最大任务)

  • 文件: transports/_monkey_document.py(新)、transports/monkey_ocr.py(仅把 _finite_number/_parse_page_size 改为从新模块导入,行为不变;新模块不得反向导入 monkey_ocr)、tests/unit/test_monkey_document.py(新)。
  • 行为: 严格按设计 §5/§5.1。异常边界(Codex 审查纠正): 广义数据异常转换放在 parse_document_package 最外层,必须同时覆盖「读取」与「全部结构解析」——except (ValueError, TypeError, KeyError, OverflowError, RecursionError, zipfile.BadZipFile, zipfile.LargeZipFile) → ResultInvalidError("OCR 文档结果包内容无效: …")(CancelledError 不在其内);_read_members 内仅包在 archive.open/read 外的窄 try 另接 RuntimeError(加密成员)/NotImplementedError(未知压缩)/zlib.error/EOFError。已是 ResultInvalidError 的原样透传(先 except ResultInvalidError: raise)。
  • 函数分解(预计均 < C,以提交前 radon 实测为准):
    • parse_document_package: 仅编排 _read_members → _parse_pages,并持有外层异常边界。
    • _read_members(zip_bytes) -> tuple[object, str]: 开 ZIP、取首个 *_middle.json、.md 按 去掉 "_middle.json" + ".md" 精确配对、读取前检查 ZipInfo.file_size(middle ≤ MAX_MIDDLE_BYTES、md ≤ MAX_MARKDOWN_BYTES)、json.loads、utf-8-sig 解码。
    • _parse_pages(payload): pdf_info 须 list 且非空;逐页 _page_elements。
    • _page_elements(page, position): 页须 dict;_parse_page_size;_page_index(page, position)(独立小函数: 须 int、非 bool、非负且等于 position,缺失同样报错);para_blocks 缺失=空页、非 list 报错。
    • _block_elements(block, page_index): 块须 dict、type 须 str 且 strip() 非空;_loose_bbox;父元素 + 非 *_body 嵌套子块各成独立元素(紧随父块、保持顺序)。
    • 文字提取拆四个小函数: _lines_text(lines) → _line_text(line) → _span_text(span)(content 须 str;inline_equation 包 $…$);_body_text(block) 只做分派,表格走 _table_text(sub_blocks)(找 table_body 子块 → 其 span 取 html,无则 latex,皆无报错),image 走 ""。
  • 测试(先红,test_monkey_document.py,合成 builder,不含任何真实文本): 设计 §5.1 契约表逐行一条,参数化列明各分支:
    • 页级: 零页;pdf_info 缺失/非 list;页非 dict;page_size 缺失/非 2 元/含 bool/非正/非有限/巨大整数;page_idx 缺失/非 int/bool/负/错位;para_blocks 缺失=空页、非 list。
    • 块级: 块非 dict;type 缺失/非 str/空串/纯空白;bbox 长度≠4/含非数值/含 bool/非有限/反序(x 与 y 各一)/零宽与零高均合法/越界页面框不被裁剪;lines 非 list/行非 dict/spans 非 list/span 非 dict/content 非 str/lines: []→"";table 取 html、取 latex、皆无、无 table_body 子块;image(用真实结构的 image_body+image_path)→"";未知类型无 lines 无 blocks→"" 且 type 保留;嵌套 caption/footnote 成独立元素且紧随父块(标「未经实测」);inline_equation 包 $;多行多 span 拼接。
    • 包级: .md 缺失/非 UTF-8/带 BOM/空串;多个 _middle.json 取首个;干扰 .md(名称不配对)不被取用;discarded_blocks 不进结果;need_drop=true 的页不失败、不影响结果;ZIP 里放垃圾 *_content_list.json 与 images/x.jpg,输出与其无关。
    • 资源与异常边界: 加密成员、未知压缩方法、CRC 损坏、声明大小超限(middle 与 md 各一)、10**400 整数出现在页结构里(而非仅 JSON 层)、深嵌套 JSON、span 内嵌套异常类型——全部 ResultInvalidError,不漏出原生异常。
    • 回归: 既有 _parse_middle_json 对相同输入行为不变(bbox 整数化退化仍拒绝、page_idx 非法仍回退枚举)。
    • docstring 验收: parse_document_package docstring 写明与 layout 路径的三处差异(零页拒绝、page_idx 严格、bbox 宽松)。
  • 验证: pytest tests/unit/test_monkey_document.py tests/unit/test_monkey_ocr.py -q --no-cov 全绿;radon cc src/polygateway/transports/_monkey_document.py src/polygateway/transports/monkey_ocr.py -n C 无输出。
  • 提交: feat: parse document result packages from middle json only。

T4 确定性 500 窄化(仅新路径可启用) — [ ] 一次提交

  • 文件: transports/monkey_ocr.py、tests/unit/test_monkey_ocr.py。
  • 行为: _classify_status(exc, source_name, operation, rejected_markers=()): 当 status == 500 且 rejected_markers 非空且响应体(小写)同时含 parsing failed: 与任一标记 → 返回 RequestRejectedError(保留 status_code=500,其余 ctx 与摘要同既有);否则走原分类。判定拆成独立小函数 _is_deterministic_failure(body, markers)。_translate_http_errors(source_name, operation, *, rejected_markers=()) 透传。MonkeyOcrTransport.__init__ 增关键字参数 deterministic_failure_markers: tuple[str, ...] = _DEFAULT_REJECTED_MARKERS,存为 self._rejected_markers。默认调用(不传 markers)行为字节级不变。
  • 测试(先红): 四个标记各一(用设计 §1 E5 的真实响应体形态 {"detail":"Parsing failed: …"})→ RequestRejectedError 且 status_code==500、摘要保留;同标记但不带 Parsing failed: 前缀 → 仍 Transient;Parsing failed: 500: Model not initialized → Transient;Unsupported file type → Transient;空 markers → 与现状一致(现有 TestErrorTranslation 全部通过);构造参数可覆盖标记集合;429/503/401 在传入 markers 时分类不变。
  • 红灯预期: 分类断言失败(返回 TransientError 而非 RequestRejectedError),不是导入/fixture 错误。
  • 验证: pytest tests/unit/test_monkey_ocr.py -q --no-cov 全绿。
  • 提交: feat: let document calls treat deterministic parse failures as rejected input。

T5 transport.parse_document — [ ] 一次提交

  • 文件: transports/monkey_ocr.py、tests/unit/test_monkey_ocr.py。
  • 行为: 把 parse_layout 的「POST /parse → 校验 success is True → 取 download_url」抽为共用私有方法(parse_layout 行为与错误消息逐字不变,传空 markers);新增 parse_document(*, document, source, call_id),并把下载拆成独立私有方法 _download_package(client, download_url, source) -> bytes:
    1. _sniff_media(document) == "unknown" → RequestRejectedError(无 status_code,operation="parse"),不发 HTTP。
    2. POST(传 self._rejected_markers)。
    3. _download_package: client.stream("GET");先处理错误状态——resp.is_error 时 await resp.aread() 再 raise_for_status()(保持响应体摘要随所有分支、保持 401/429/5xx 分类);成功状态之后才检查 Content-Length 超 _MAX_PACKAGE_BYTES 即拒,并对 aiter_bytes() 逐块累计,超限 → ResultInvalidError(不得让结果包资源检查改变错误分类)。
    4. await asyncio.to_thread(parse_document_package, content)。
    5. 返回 OcrDocumentTransportResult(raw={"success": True, "download_url": …})。
  • 测试(先红):
    • 全链路(相对与绝对 download_url)、markdown 与元素取值;parse_layout 既有 TestParseLayout 全部通过。
    • 新入口上传标签矩阵: PDF/前缀垃圾 PDF → doc.pdf;图片 → image.jpg;未知字节 → 本地 RequestRejectedError 且 MockTransport 收到 0 次请求。
    • POST 返回四个标记之一 → RequestRejectedError(500),未知 500 → Transient;success!=true → RequestRejectedError(status_code=200);GET 的 401/429/500 响应体摘要保留且分类不变;错误状态 + 超限 Content-Length 仍按 HTTP 分类(不得变成 ResultInvalidError);成功状态下 Content-Length 超限与多块累计超限 → ResultInvalidError。
    • 解析期间取消不占事件循环(写法固定): monkeypatch 把 transport 引用的 parse_document_package 换成同步桩,桩内 threading.Event.wait(timeout=5) 阻塞,进入时用 loop.call_soon_threadsafe(entered.set) 通知(不得在线程里直接用 asyncio.Event);测试 await entered.wait(),确认另一个协程仍能运行,然后 task.cancel(),在释放线程事件之前断言 CancelledError;finally 释放并等待线程收尾。
  • 验证: pytest tests/unit/test_monkey_ocr.py tests/unit/test_monkey_document.py -q --no-cov 全绿;radon 同 T3。
  • 提交: feat: add parse_document to the MonkeyOCR transport。

T6 OcrClient.parse_document — [ ] 一次提交

  • 文件: ocr.py、tests/unit/test_ocr_client.py、tests/unit/test_telemetry.py。
  • 行为: 按 §2 签名新增公共方法(与 parse_layout 对称): 先 validate_caller_dimensions(origin="parse_document(tenant_id=..., meta=...)"),再 _document_transport() 校验(isinstance(self._transport, OcrDocumentTransport),否则 TypeError,早于 _CallContext)。其余改动:
    • _OcrKind 增 "document";私有方法形参 image→payload。旧入口错误文案不变: _call 的校验文案按 kind 取名(text/layout 仍是「image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)」「image 不能为空」,document 为「document 必须是 bytes」「document 不能为空」)。
    • 四处结果联合类型同步: _AttemptOutcome.result(ocr.py:91)、_invoke 返回(:486)、_emit 的 result(:582)、_summarize 入参(:625)都加 OcrDocumentTransportResult;用于 isinstance 的 OcrDocumentTransport 与三种 TransportResult 从 TYPE_CHECKING 提到运行时导入。
    • _invoke 的 document 分支调 _document_transport().parse_document(document=payload, ...);_summarize 改 isinstance 三分支(document→<document elements=N pages=M md_chars=K>,text→截断正文,layout→原摘要);占位字面量不变 <ocr:document image_bytes=N>(_request_for 的 f-string 单点)。
    • 遥测调用点 _emit 照旧传 reasoning_applies=False(单一 helper 铁律,不复制参数列表)。
  • 测试(先红):
    • test_ocr_client.py: ScriptedOcrTransport 增 parse_document 与脚本动作 "document"(并把现有「非 text 一律 layout」改为三分支);成功路径字段(元素/markdown/usage(0,0)/call_stats/gate 记成功);输入校验(TypeError/ValueError 与旧文案不变的回归);transport 缺 parse_document → TypeError 且无遥测行、无统计;call_deadline_s 非法值/继承值与 parse_layout 同行为;换源/退避/ResultInvalid 不计窗口(gate.successes==[("m1", False)])、本地拒绝不记成功、status_code=500 的 RequestRejectedError 记成功不计窗口且不喂选源器;超时 Transient → 计失败 → 重试(文档路径的对照);429 → 照扣重试预算、熔断窗口不变;取消穿透且在飞探针归还。
    • 无延迟判慢: 扩展 ClockAdvancingOcrTransport 的 "document" 动作,source timeout_s 设小值,在成功尝试内把 FakeClock 推进远超它;断言成功、gate 为 CLOSED、fails==0。分别构造两种前置状态: 「CLOSED 但已有失败计数」与「OPEN 冷却后的半开探针」(门与 client 共用同一注入时钟,显式配置 probe_ttl_s/租约 TTL,避免把租约接管混进断言)。
    • 持续慢速 → CallDeadlineExceeded(写法固定,真实事件循环时间): 真实 MonkeyOcrTransport + httpx.MockTransport,POST 返回合法 download_url,GET 返回自定义 httpx.AsyncByteStream,每产出一个小块前 await asyncio.sleep(0.01);call_deadline_s=0.1;经真实 OcrClient 调用,断言 CallDeadlineExceeded(不是 TransientError)、流已关闭、permit 释放、探针归还、熔断窗口不变,并用计数证明确实已进入多块下载。局限写进测试 docstring: MockTransport 不执行 httpcore 的逐次读超时,此用例证明整体期限而非 read timeout。
    • 真实解析异常贯穿客户端: 真实 MonkeyOcrTransport + MockTransport 返回含 10**400 整数 / 加密成员的 ZIP,经真实 OcrClient,断言 ResultInvalidError、终态遥测行已写、半开探针已归还、熔断窗口不变(不能只用桩直接抛预制 ResultInvalidError)。
    • test_telemetry.py(其 _StubOcrTransport 同步增 parse_document): operation 参数化加 parse_document;文档行 operation=="parse_document"、messages 占位、response 摘要含 md_chars 且不含正文、tenant_id/meta 落库、记录行 reasoning_effort is None(reasoning_applies=False 是传给 emitter 的参数,落库字段是 reasoning_effort)、终态失败行类名前缀;_summarize 三类各一条;text_cap 对文档占位同样生效。
  • 验证: pytest tests/unit/test_ocr_client.py tests/unit/test_telemetry.py tests/unit/test_ports.py -q --no-cov 全绿;make lint;radon cc src/polygateway/ocr.py -n C 无新增输出。
  • 提交: feat: add OcrClient.parse_document with full governance。

T7 注册面与对齐工具 — [ ] 一次提交

  • 文件: tools/check_wiki_alignment.py、research-wiki/schemas/llm-calls.md(operation 取值,第 35 行附近)、research-wiki/metrics/ocr-call-success.md、research-wiki/metrics/call-telemetry-coverage.md、make wiki 重建的 research-wiki/{index.md,log.md,graph/edges.json}。
  • 行为: _SIGNATURE_TARGETS 加 ("OcrClient.parse_document", OcrClient.parse_document);三份 wiki 页补 parse_document。
  • 红绿证据: 红 = 改动前执行 conda run -n PolyGateway python -c "import runpy; ns = runpy.run_path('tools/check_wiki_alignment.py', run_name='x'); assert any(n == 'OcrClient.parse_document' for n, _ in ns['_SIGNATURE_TARGETS'])" 以 AssertionError 退出非零;绿 = 改动后同命令退出 0。make wiki-check 若依赖外部 Gitea Wiki 仓库而不可达,如实记录「不可达,未执行」,不编造通过。
  • 提交: docs: register parse_document in telemetry schema and alignment targets。

T8 活体集成用例(slow,opt-in) — [ ] 一次提交

  • 文件: tests/integration/test_monkey_ocr_document_live.py(新文件,模块级 pytestmark = pytest.mark.slow,不带图片语料的 skipif)。
  • 行为: 测试内手写最小合法 PDF 构造函数 _minimal_pdf(text)(5 个对象、计算 xref 偏移、大号 Helvetica 文字,无第三方内容、无新依赖)。统一放在 class TestLiveDocument,三条用例,串行、单份小文档(服务与医院线共用,不压测):
    1. test_parse_document_returns_pages_and_text: PDF 文字取 POLYGATEWAY SYNTHETIC SAMPLE;断言 ≥1 页、page_sizes[0] 均为正、"SYNTHETIC" 出现在 markdown.upper() 与某个元素 text.upper() 中(正向断言,空串不能通过)。
    2. test_recognize_text_on_pdf_is_not_the_hallucination: 同一 PDF 的 recognize_text: text.strip() 非空且 "single character" 不在其中,并含 "SYNTHETIC"。
    3. test_corrupt_documents_do_not_open_the_breaker: 以阈值 2 的环境,连续两份截断但保留 %PDF- 头的 PDF → 均 RequestRejectedError;随后有效 PDF 仍成功(若坏文档被误计失败,两次即达阈值,第三次会被熔断拒绝)。
  • 红绿证据: 本任务是活体验收,不存在「先红」;诚实记录为「无行为红灯,证据 = 单元层 T2/T4/T5 的红绿 + 本任务的活体输出」。手动命令选中全部三条: pytest tests/integration/test_monkey_ocr_document_live.py -m slow -q --no-cov,输出存 tests/outputs/issue34/(不提交);服务不可达时如实记录,不 skip 掩盖。默认套件下 pytest tests/integration/test_monkey_ocr_document_live.py -q --no-cov 应显示 3 deselected。
  • 提交: test: add live PDF document parsing checks。

T9 文档与版本 — [ ] 一次提交(bump 不得裸发,CLAUDE.md §4.4.1)

  • 性质: 文档/版本任务,无行为红灯;证据 = test_package 的版本一致性用例 + 对版本串与方法名的 grep 输出。
  • 文件与内容:
    • pyproject.toml 与 src/polygateway/__init__.py 的版本 → 1.3.11。
    • CHANGELOG.md: 新增「未发布」段(发布时定版): 新增公共面(parse_document/OcrDocumentElement/OcrDocumentResult/OcrDocumentPort/OcrDocumentTransport/CallOperation "parse_document");行为变更: PDF 上传改标 doc.pdf(recognize_text 修复静默错误、parse_layout(PDF) 上传分支变更并写明已测[文本型、位图型]与未测[旋转页、批注页]范围);新路径确定性坏输入归「请求被拒」;已知限制: 服务端丢弃脚注/页眉页脚/页码、markdown 含悬空 images/ 引用、多行/多 span/inline_equation/嵌套 caption 路径未经实测、parse_document 不接受魔数表之外的图片格式;关联 #34,后续 #35/#36。
    • README.md: OCR 小节加 parse_document 用法与能力表一行、方法数、文档线配置示例;设计 §7 的运维要点逐条落字: ① 文档线独立 scope、MAX_CONCURRENCY=2、RETRY__MAX_ATTEMPTS=2、TIMEOUT_S ≥ 最大页数×1.5s×余量(争用取 5 倍以上)、OCR_DOC__CALL_DEADLINE_S ≤ PGW_LEASE_TTL_S(timeout_s 不是总耗时上界);② 重试放大(GET 失败会重新 POST 整份文档重算);③ 无页数预检,过滤归调用方;④ 服务端磁盘产物累积,需告知服务运维;⑤ 多 worker 共享并发上限需 PGW_LIMITER_BACKEND=redis 且同 scope;⑥ sync-only 后端的排队与对医院线的队头阻塞、须向运维确认 backend(启动日志 async-capable/sync-only);⑦ 服务丢弃脚注等;⑧ 租约守卫只比 1× timeout_s 的缺口与「守卫改造另行立项」。安装命令的版本约束逐项核对(CLAUDE.md §4.4.1 第 1 步)。
    • .env.example: OCR 段加文档线独立 scope 示例(OCR_DOC__MONKEY__1__*、OCR_DOC__CALL_DEADLINE_S 等,键名以 config.py 为准)。
    • research-wiki/ARCHITECTURE.md: §5.1 类型清单、§7.10 端口表加 OcrDocumentPort、D9 注记、:597 的 operation 枚举加 parse_document、:433 的「四个公共方法」补新增入口;designs/2026-07-21-m3-ocr-design.md §4 加勘误(「服务按内容处理」只验证图片,指向本设计 E3/E4/E10);migrations/chsanalyzer.md 加 CHSAnalyzer3 新消费路径与 H3 待确认项。
    • research-wiki/ROADMAP.md 若有 OCR 状态行则同步。
  • 提交: docs: document parse_document and bump version to 1.3.11。

T10 验证与审查(合并前硬门)— [ ] 验收任务,无独立红灯;审查修复另提

  1. make ci(只读验证: ruff format --check + ruff check + lint-imports + 全套件)——输出重定向文件取退出码,不接管道。
  2. 覆盖率: 新增/改动模块行覆盖率 ≥ 80%,未覆盖行逐一说明;对改动的三个模块运行 radon,以实测为准报告。
  3. 派全新上下文 verifier subagent(verification-before-completion): 逐条对照设计 §4-§10 与本计划验收标准,只读核对测试证据(红灯日志存在且失败原因正确);任何声明须对应本会话工具输出。
  4. requesting-code-review 整分支审查(含 Codex --fresh --wait);逐条核验后修订,不盲从。
  5. 活体: T8 手动跑一次的输出;并用本会话在 /tmp/claude-1000/-home-iomgaa-Projects-PolyGateway/a9dd153e-445b-42f7-9808-4ac914c3f176/scratchpad/ocr/ 里本地存证的样本(x/、r/、t/、scanA/,第三方内容,不入库)对 parse_document_package 做离线冒烟: 先检查目录存在,核对 elements 数量 = middle 的 para_blocks 总数(29/89/30/29)、text 与 content_list 反转义后一致、markdown 等于 .md 原文;样本不存在则在报告中列为未验证项,不得声称已验证。
  6. 最终报告 ≤300 行,写入 research-wiki/reviews/;向人类汇报未验证项(多行/多 span、嵌套 caption、旋转/批注页差分、线上 backend、CHSAnalyzer3 延期语义、真实 httpcore read-timeout 行为)。
  7. 停在此处,等待人类的发布指令(合并 main、打 tag、构建、上传、验证已发布、建 Release、关闭 #34 逐步执行,CLAUDE.md §4.4.1)。

4. 自审清单

检查 结论
设计每条决策有对应任务 Q1→T1/T6;Q2→T2/T5;Q3→T3;Q4→T4/T5;Q5 与 §7 运维要点→T6(测试)/T9(逐条落字);§5.1 契约→T3(参数化逐分支);§6 分类表→T4-T6;§8 旧行为→T2/T5/T3 回归断言;H5/H6 变更说明→T9 CHANGELOG
跨任务签名一致 parse_document_package 返回三元组(T3)被 T5 to_thread 消费;rejected_markers(T4)被 T5 透传;_document_transport()(T6)是唯一 isinstance 点;四处结果联合类型(T6)已逐处列出
无占位语 无 TBD/TODO;关键接口已在 §2 给出实际签名;时间/并发类测试的写法已固定
复杂度 只能「预计」;T3 已拆到 _line_text/_span_text/_table_text/_page_index;每次提交前 radon 实测
证据完整性 每个行为任务写明红灯预期;T7 有可执行红绿命令;T8-T10 如实声明无行为红灯
范围 不含 #35/#36 的改动;不含发布动作;不改 parse_layout 的校验口径与旧入口文案;不动既有 test_monkey_ocr_live.py

5. 计划审查记录

Codex 一轮只读审查,结论「修订后进入执行」。逐条核验并全部采纳(第 1 条为我方计划自身的缺陷): ① T3 异常边界放错位置 → 移到最外层(已验证 _parse_pages 在 _read_members 之后执行);② T8 与既有集成测试文件冲突 → 改新文件(已验证默认收集 6 项旧用例);③ 三类时间/取消测试写法 → 已固定;④ T5 顺序 → 先错误状态后体积;⑤ T6 遥测字段名与联合类型 → 订正(已验证落库字段是 reasoning_effort);⑥ 覆盖缺口 → 补入 T3/T5/T9;⑦ 红绿证据 → 逐任务声明;⑧ T8 断言强度与 T10 样本前置 → 订正;⑨ 复杂度表述 → 改「预计」;⑩ 文件清单/文案兼容 → 订正。