944b3f3798
Ten tasks covering types and ports, upload sniffing, package parsing, rejected-input classification, transport, client, registration, live checks, docs and verification.
24 KiB
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";OcrLayoutElementdocstring 加「追加带默认值字段前须评估子类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、TIFFII*\x00/MM\x00*、BMPBM、GIFGIF87a/GIF89a、WebPRIFF????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 验证与审查(合并前硬门)— [ ] 无代码提交(审查修复另提)
make ci(只读验证:ruff format --check+ruff check+lint-imports+ 全套件)——输出重定向文件取退出码,不接管道。- 覆盖率: 新增/改动模块行覆盖率 ≥ 80%,未覆盖行逐一说明。
- 派全新上下文 verifier subagent(
verification-before-completion): 逐条对照设计 §4-§10 与本计划验收标准,只读核对测试证据(红灯日志存在且失败原因正确);任何声明须对应本会话工具输出。 requesting-code-review整分支审查(含 Codex--fresh --wait);逐条核验后修订,不盲从。- 活体: T8 手动跑一次的输出;并用
scratchpad/ocr/里本地存证的 4 份样本(x/r/t/scanA,第三方内容,不入库)对parse_document_package做一次离线冒烟,核对elements数量 = middle 的para_blocks总数(29/89/30/29)、text与content_list反转义后一致、markdown等于.md原文;结果入最终报告。 - 最终报告 ≤300 行,写入
research-wiki/reviews/;向人类汇报未验证项(多行/多 span、嵌套 caption、旋转/批注页差分、线上 backend、CHSAnalyzer3 延期语义)。 - 停在此处,等待人类的发布指令(合并 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 的校验口径 |