Files
PolyGateway/research-wiki/plans/2026-10-21-issue34-ocr-document.md
T
iomgaa 944b3f3798 docs: add implementation plan for OCR document parsing
Ten tasks covering types and ports, upload sniffing, package parsing, rejected-input classification, transport, client, registration, live checks, docs and verification.
2026-10-21 22:51:10 -04:00

24 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 执行模式)
层次 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_monkey_ocr,test_ocr_client,test_telemetry}.py 改 见各任务
tests/unit/test_monkey_document.py 新 结果包解析的契约用例(设计 §5.1 逐行一条)
tests/integration/test_monkey_ocr_live.py 改 slow 活体用例(T8)
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。
  • 行为: 按 §2 增加三个数据类与两个 Protocol;CallOperation/CALL_OPERATIONS 增 "parse_document";OcrLayoutElement docstring 加「追加带默认值字段前须评估子类 OcrDocumentElement(无默认字段会在导入期 TypeError)」;OcrDocumentResult 不继承 OcrLayoutResult,docstring 写明原因。
  • 测试(先红):
    • 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 改为从新模块导入,行为不变)、tests/unit/test_monkey_document.py(新)。
  • 行为: 严格按设计 §5/§5.1。函数分解(为过 radon < C):
    • parse_document_package(zip_bytes): 仅编排 _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 解码。异常边界(设计 §5): 函数外层 except (ValueError, TypeError, KeyError, OverflowError, RecursionError, zipfile.BadZipFile, zipfile.LargeZipFile);仅包在 archive.open/read 外的窄 try 接 RuntimeError/NotImplementedError/zlib.error/EOFError;统一抛 ResultInvalidError("OCR 文档结果包内容无效: …"),CancelledError 不在其内。
    • _parse_pages(payload): pdf_info 须 list 且非空;逐页 _page_elements(page, position)。
    • _page_elements(page, position): 页须 dict;_parse_page_size;page_idx 须 int、非 bool、非负且等于 position;para_blocks 缺失=空页、非 list 报错;逐块 _block_elements。
    • _block_elements(block, page_index): 块须 dict、type 须非空 str;_loose_bbox(长度 4 有限数、x1≤x2 且 y1≤y2,无整数化退化校验);父元素 + 非 *_body 嵌套子块各成独立元素(紧随父块)。
    • _block_text(block) → _lines_text(lines)(行内 span 按 "" 拼、行间按 "\n" 拼;inline_equation 包 $…$;span 须 dict 且 content 为 str)与 _body_text(block)(table: body span 取 html,无则 latex,皆无报错;image: "")。
  • 测试(先红,test_monkey_document.py,合成 builder,不含任何真实文本): 设计 §5.1 契约表逐行一条——零页、pdf_info 缺失/非 list、页非 dict、page_size 非法、page_idx 为 bool/负/错位、para_blocks 缺失=空页/非 list、块无 type、bbox 反序/非有限/零宽合法、lines 各层类型错误、lines: []→""、table 取 html/取 latex/皆无报错、image→""、未知类型无 lines 无 blocks→"" 且 type 保留、嵌套 caption/footnote 成独立元素(标注「未经实测」)、inline_equation 包 $、多行多 span 拼接、.md 缺失/非 UTF-8/带 BOM/空串;内容与 content_list 解耦(ZIP 里放垃圾 *_content_list.json 与 images/x.jpg,输出与其无关);资源与异常边界——加密成员、未知压缩方法、10**400 整数、深嵌套 JSON、CRC 损坏、声明大小超限成员,全部 ResultInvalidError 且不漏出原生异常;discarded_blocks 不进结果。另加一条回归: 既有 _parse_middle_json 对同样输入的行为不变(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 -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 与摘要同既有);否则走原分类。_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 现有参数化全部通过);构造参数可覆盖标记集合。
  • 验证: 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): ① _sniff_media(document) == "unknown" → RequestRejectedError(无 status_code,operation="parse"),不发 HTTP;② POST(传 self._rejected_markers);③ 流式 GET(client.stream): 先看 Content-Length 超 _MAX_PACKAGE_BYTES 即拒;resp.is_error 时先 await resp.aread() 再 raise_for_status()(保持响应体摘要随所有分支);逐块累计,超限 → ResultInvalidError;④ await asyncio.to_thread(parse_document_package, content);⑤ 返回 OcrDocumentTransportResult(raw={"success": True, "download_url": …})。
  • 测试(先红): 全链路(相对与绝对 download_url);未知字节本地拒绝且 MockTransport 收到 0 次请求;POST 返回四个标记之一 → RequestRejectedError(500),未知 500 → Transient;success!=true → RequestRejectedError(status_code=200);GET 4xx/5xx 的响应体摘要保留;整包流式超限(多个小块累计)与 Content-Length 超限 → ResultInvalidError;解析期间取消 → 立即 CancelledError(用阻塞式 parse_document_package 桩 + asyncio.Event 确定性窗口,断言事件循环未被占住);parse_layout 既有 TestParseLayout 全部通过。
  • 验证: pytest tests/unit/test_monkey_ocr.py tests/unit/test_monkey_document.py -q --no-cov 全绿。
  • 提交: 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 校验文案改为「payload 必须是 bytes」「payload 不能为空」);_invoke 的 document 分支调 _document_transport().parse_document(document=payload, ...);_summarize 改 isinstance 三分支(document→<document elements=N pages=M md_chars=K>,text→截断正文,layout→原摘要),并把结果类型从 TYPE_CHECKING 提到运行时导入;占位字面量不变 <ocr:document image_bytes=N>(_request_for 的 f-string 单点)。
  • 测试(先红):
    • test_ocr_client.py: ScriptedOcrTransport 增 parse_document 与脚本动作 "document";成功路径字段(元素/markdown/usage(0,0)/call_stats/gate 记成功);输入校验(TypeError/ValueError);transport 缺 parse_document → TypeError 且无遥测行、无统计;换源/退避/ResultInvalid 不计窗口(gate.successes==[("m1", False)])、本地拒绝不记成功、带 status_code=500 的 RequestRejectedError 记成功不计窗口且不喂选源器;无延迟判慢(ClockAdvancingOcrTransport 推进远大于 timeout_s,断言成功、fails==0;含「熔断已有失败计数」与半开探针两种前置状态);持续慢速场景(总耗时超 call_deadline_s)→ CallDeadlineExceeded 而非 Transient,且熔断窗口不变;429 → 照扣重试预算、熔断窗口不变;取消穿透且在飞探针归还。
    • test_telemetry.py(其 _StubOcrTransport 同步增 parse_document): operation 参数化加 parse_document;文档行 operation=="parse_document"、messages 占位、response 摘要含 md_chars 且不含正文、tenant_id/meta 落库、reasoning_applies 恒为 NULL、终态失败行类名前缀;_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。
  • 提交: 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(无推理入口列表)。
  • 行为: _SIGNATURE_TARGETS 加 ("OcrClient.parse_document", OcrClient.parse_document);三份 wiki 页补 parse_document。
  • 测试: 若 make wiki-check 依赖外部 Gitea Wiki 仓库而不可达,如实记录「不可达,未执行」,不编造通过;可本地验证的部分: 工具 import 成功、_SIGNATURE_TARGETS 含新项。
  • 验证: conda run -n PolyGateway python -c "import runpy; runpy.run_path('tools/check_wiki_alignment.py', run_name='x')" 无异常;make wiki 重建索引。
  • 提交: docs: register parse_document in telemetry schema and alignment targets。

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

  • 文件: tests/integration/test_monkey_ocr_live.py。
  • 行为: 测试内手写最小合法 PDF 构造函数(_minimal_pdf(text): 5 个对象、计算 xref 偏移,无第三方内容、无新依赖),三条 @pytest.mark.slow 用例,串行、单份小文档(服务与医院线共用,不压测): ① parse_document 返回 ≥1 页、page_sizes[0] 为正、至少一个元素 text 非空、markdown 非空;② 同一 PDF 的 recognize_text 返回的文本不是 E4 的幻觉串("single character" 不在其中);③ 截断但保留 %PDF- 头的 PDF → RequestRejectedError,随后同一 client 再调有效 PDF 仍成功(熔断阈值 2 的环境下证明坏文档未把熔断打开)。
  • 验证: 日常套件不跑(默认 -m 'not slow'): pytest tests/integration/test_monkey_ocr_live.py -q --no-cov 显示 deselected;执行阶段手动跑一次 -m slow -k parse_document 取得活体证据(输出存 tests/outputs/134/ 同级的 tests/outputs/issue34/ ,不提交)。服务不可达时如实记录,不 skip 掩盖。
  • 提交: test: add live PDF document parsing checks。

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

  • 文件与内容:
    • pyproject.toml 与 src/polygateway/__init__.py 的版本 → 1.3.11(test_package 钉两处一致)。
    • 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 路径未经实测);配置指引(文档线独立 scope、call_deadline_s ≤ lease_ttl_s、sync-only 并发 1);关联 #34,后续 #35/#36。
    • README.md: OCR 小节加 parse_document 用法与能力表一行、方法数、文档线配置示例、服务限制;安装命令的版本约束逐项核对(CLAUDE.md §4.4.1 第 1 步,极易漏改)。
    • .env.example: OCR 段加文档线独立 scope 示例(OCR_DOC__MONKEY__1__*,MAX_CONCURRENCY=2、RETRY__MAX_ATTEMPTS=2、TIMEOUT_S 公式注释、call_deadline_s 键)。
    • research-wiki/ARCHITECTURE.md: §5.1 类型清单、§7.10 端口表加 OcrDocumentPort、D9 注记;designs/2026-07-21-m3-ocr-design.md §4 加勘误(「服务按内容处理」只验证图片,指向本设计 E3/E4/E10);migrations/chsanalyzer.md 加 CHSAnalyzer3 新消费路径与 H3 待确认项。
    • research-wiki/ROADMAP.md 若有 OCR 状态行则同步。
  • 测试: pytest tests/unit/test_package.py -q --no-cov;文档类无行为测试,以 grep 核对版本串与方法名一致(命令与输出入最终报告)。
  • 提交: docs: document parse_document and bump version to 1.3.11。

T10 验证与审查(合并前硬门)— [ ] 无代码提交(审查修复另提)

  1. make ci(只读验证: ruff format --check + ruff check + lint-imports + 全套件)——输出重定向文件取退出码,不接管道。
  2. 覆盖率: 新增/改动模块行覆盖率 ≥ 80%,未覆盖行逐一说明。
  3. 派全新上下文 verifier subagent(verification-before-completion): 逐条对照设计 §4-§10 与本计划验收标准,只读核对测试证据(红灯日志存在且失败原因正确);任何声明须对应本会话工具输出。
  4. requesting-code-review 整分支审查(含 Codex --fresh --wait);逐条核验后修订,不盲从。
  5. 活体: T8 手动跑一次的输出;并用 scratchpad/ocr/ 里本地存证的 4 份样本(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 延期语义)。
  7. 停在此处,等待人类的发布指令(合并 main、打 tag、构建、上传、验证已发布、建 Release、关闭 #34 逐步执行,CLAUDE.md §4.4.1)。

4. 自审清单(写完计划后逐项过)

检查 结论
设计每条决策有对应任务 Q1→T1/T6;Q2→T2;Q3→T3;Q4→T4/T5;Q5(文档+测试)→T6/T9;§5.1 契约→T3;§6 分类表→T4-T6;§7 配置口径→T9;§8 旧行为→T2/T5 回归断言;H5/H6 变更说明→T9 CHANGELOG
跨任务签名一致 parse_document_package 返回三元组(T3)被 T5 to_thread 消费;rejected_markers 形参(T4)被 T5 透传;_document_transport()(T6)是唯一 isinstance 点
无占位语 无 TBD/TODO;关键接口已在 §2 给出实际签名
复杂度 T3 已按函数拆分;T4/T5/T6 的新增分支各自 ≤ B 级,提交门会机械复核
范围 不含 #35/#36 的改动;不含发布动作;不改 parse_layout 的校验口径