docs: fold plan review findings into OCR document plan

This commit is contained in:
2026-10-21 23:14:22 -04:00
parent 944b3f3798
commit b31df842a8
@@ -24,6 +24,7 @@ date: 2026-10-21
| 先红后绿 | 每个任务先写测试并**亲眼看到因目标行为缺失而失败**,红灯输出存 `scratchpad/<任务>-red.log`,再实现到绿。测试结果门要求的证据,最终报告逐条引用 | | 先红后绿 | 每个任务先写测试并**亲眼看到因目标行为缺失而失败**,红灯输出存 `scratchpad/<任务>-red.log`,再实现到绿。测试结果门要求的证据,最终报告逐条引用 |
| 提交门 | `.claude/scripts/hooks/pre-commit-guard.sh` 在提交时跑**全套** `pytest tests/` 并拒绝「本次改动触及的函数圈复杂度 ≥ C」(radon ≥ 11)。故所有新函数须拆小(见 T3 的分解),提交必须调用 `commit` skill、**不带 AI 签名** | | 提交门 | `.claude/scripts/hooks/pre-commit-guard.sh` 在提交时跑**全套** `pytest tests/` 并拒绝「本次改动触及的函数圈复杂度 ≥ C」(radon ≥ 11)。故所有新函数须拆小(见 T3 的分解),提交必须调用 `commit` skill、**不带 AI 签名** |
| 命令 | 一律 `conda run -n PolyGateway ...`;长命令输出重定向到文件并**直接取退出码**,不接管道(CLAUDE.md §3 执行模式) | | 命令 | 一律 `conda run -n PolyGateway ...`;长命令输出重定向到文件并**直接取退出码**,不接管道(CLAUDE.md §3 执行模式) |
| 复杂度 | 新函数的复杂度在实现前只能标「预计」,**每次提交前对改动模块跑 `radon cc <文件> -n C`**,有输出即拆分;不得提前宣称已通过 |
| 层次 | `import-linter` 契约:`ports/types/errors` 最内层;`transports/` 只实现端口、不依赖 `middleware/`;每个任务结束跑 `make lint` | | 层次 | `import-linter` 契约:`ports/types/errors` 最内层;`transports/` 只实现端口、不依赖 `middleware/`;每个任务结束跑 `make lint` |
| 中文 | 模块/类/方法中文 docstring;注释解释「为什么」;日志用 loguru,禁 `print` | | 中文 | 模块/类/方法中文 docstring;注释解释「为什么」;日志用 loguru,禁 `print` |
| 不顺手清理 | 只动本计划列出的文件与函数;`parse_layout` 的行为(bbox 整数化退化校验、`page_idx` 回退、无体积上限)**保持原样** | | 不顺手清理 | 只动本计划列出的文件与函数;`parse_layout` 的行为(bbox 整数化退化校验、`page_idx` 回退、无体积上限)**保持原样** |
@@ -41,9 +42,10 @@ date: 2026-10-21
| `src/polygateway/__init__.py` | 改 | 导出 `OcrDocumentElement`/`OcrDocumentResult`(版本号放 T9) | | `src/polygateway/__init__.py` | 改 | 导出 `OcrDocumentElement`/`OcrDocumentResult`(版本号放 T9) |
| `tools/check_wiki_alignment.py` | 改 | `_SIGNATURE_TARGETS` 加 `OcrClient.parse_document` | | `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 取值与摘要格式 | | `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_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/unit/test_monkey_document.py` | **新** | 结果包解析的契约用例(设计 §5.1 逐行一条) |
| `tests/integration/test_monkey_ocr_live.py` | 改 | `slow` 活体用例(T8) | | `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 | | `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. 跨任务关键接口(执行时以此为准) ## 2. 跨任务关键接口(执行时以此为准)
@@ -114,8 +116,8 @@ async def parse_document(
### T1 内核类型与端口 — [ ] 一次提交 ### T1 内核类型与端口 — [ ] 一次提交
- **文件**: `types.py`、`ports.py`、`__init__.py`(仅导出)、`tests/unit/test_types.py`、`tests/unit/test_ports.py`。 - **文件**: `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 写明原因。 - **行为**: 按 §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_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_document_types_frozen`: 三个新类型赋值抛 `FrozenInstanceError`。
@@ -135,87 +137,118 @@ async def parse_document(
### T3 结果包解析纯函数 — [ ] 一次提交(本计划最大任务) ### T3 结果包解析纯函数 — [ ] 一次提交(本计划最大任务)
- **文件**: `transports/_monkey_document.py`(新)、`transports/monkey_ocr.py`(仅把 `_finite_number`/`_parse_page_size` 改为从新模块导入,行为不变)、`tests/unit/test_monkey_document.py`(新)。 - **文件**: `transports/_monkey_document.py`(新)、`transports/monkey_ocr.py`(仅把 `_finite_number`/`_parse_page_size` 改为从新模块导入,行为不变;新模块**不得**反向导入 `monkey_ocr`)、`tests/unit/test_monkey_document.py`(新)。
- **行为**: 严格按设计 §5/§5.1。**函数分解(为过 radon < C)**: - **行为**: 严格按设计 §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`)。
- `parse_document_package(zip_bytes)`: 仅编排 `_read_members` → `_parse_pages` → 返回。 - **函数分解(预计均 < C,以提交前 radon 实测为准)**:
- `_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_document_package`: 仅编排 `_read_members` → `_parse_pages`,并持有外层异常边界。
- `_parse_pages(payload)`: `pdf_info` 须 list 且**非空**;逐页 `_page_elements(page, position)`。 - `_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` 解码。
- `_page_elements(page, position)`: 页须 dict;`_parse_page_size`;`page_idx` 须 int、非 bool、非负且**等于 position**;`para_blocks` 缺失=空页、非 list 报错;逐块 `_block_elements`。 - `_parse_pages(payload)`: `pdf_info` 须 list 且**非空**;逐页 `_page_elements`。
- `_block_elements(block, page_index)`: 块须 dict、`type` 须非空 str;`_loose_bbox`(长度 4 有限数、`x1≤x2` 且 `y1≤y2`,**无**整数化退化校验);父元素 + 非 `*_body` 嵌套子块各成独立元素(紧随父块)。 - `_page_elements(page, position)`: 页须 dict;`_parse_page_size`;`_page_index(page, position)`(独立小函数: 须 int、非 bool、非负且等于 position,缺失同样报错);`para_blocks` 缺失=空页、非 list 报错。
- `_block_text(block)` → `_lines_text(lines)`(行内 span 按 `""` 拼、行间按 `"\n"` 拼;`inline_equation` 包 `$…$`;span 须 dict 且 `content` 为 str)与 `_body_text(block)`(table: body span 取 `html`,无则 `latex`,皆无报错;image: `""`)。 - `_block_elements(block, page_index)`: 块须 dict、`type` 须 str 且 `strip()` 非空;`_loose_bbox`;父元素 + 非 `*_body` 嵌套子块各成独立元素(紧随父块、保持顺序)。
- **测试(先红,`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 整数化退化仍拒绝)。 - 文字提取拆四个小函数: `_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 走 `""`。
- **验证**: `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` 无输出。 - **测试(先红,`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`。 - **提交**: `feat: parse document result packages from middle json only`。
### T4 确定性 500 窄化(仅新路径可启用) — [ ] 一次提交 ### T4 确定性 500 窄化(仅新路径可启用) — [ ] 一次提交
- **文件**: `transports/monkey_ocr.py`、`tests/unit/test_monkey_ocr.py`。 - **文件**: `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)行为字节级不变。** - **行为**: `_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` 现有参数化全部通过);构造参数可覆盖标记集合。 - **测试(先红)**: 四个标记各一(用设计 §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` 全绿。 - **验证**: `pytest tests/unit/test_monkey_ocr.py -q --no-cov` 全绿。
- **提交**: `feat: let document calls treat deterministic parse failures as rejected input`。 - **提交**: `feat: let document calls treat deterministic parse failures as rejected input`。
### T5 transport.parse_document — [ ] 一次提交 ### T5 transport.parse_document — [ ] 一次提交
- **文件**: `transports/monkey_ocr.py`、`tests/unit/test_monkey_ocr.py`。 - **文件**: `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": …})`。 - **行为**: 把 `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`:
- **测试(先红)**: 全链路(相对与绝对 `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` 全部通过。 1. `_sniff_media(document) == "unknown"` → `RequestRejectedError`(无 `status_code`,`operation="parse"`),**不发 HTTP**。
- **验证**: `pytest tests/unit/test_monkey_ocr.py tests/unit/test_monkey_document.py -q --no-cov` 全绿。 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`。 - **提交**: `feat: add parse_document to the MonkeyOCR transport`。
### T6 OcrClient.parse_document — [ ] 一次提交 ### T6 OcrClient.parse_document — [ ] 一次提交
- **文件**: `ocr.py`、`tests/unit/test_ocr_client.py`、`tests/unit/test_telemetry.py`。 - **文件**: `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 单点)。 - **行为**: 按 §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"`;成功路径字段(元素/`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_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 → 照扣重试预算、熔断窗口不变;取消穿透且在飞探针归还。
- `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` 对文档占位同样生效。 - **无延迟判慢**: 扩展 `ClockAdvancingOcrTransport` 的 `"document"` 动作,source `timeout_s` 设小值,在成功尝试内把 `FakeClock` 推进远超它;断言成功、gate 为 CLOSED、`fails==0`。分别构造两种前置状态: 「CLOSED 但已有失败计数」与「OPEN 冷却后的半开探针」(门与 client 共用同一注入时钟,显式配置 `probe_ttl_s`/租约 TTL,避免把租约接管混进断言)。
- **验证**: `pytest tests/unit/test_ocr_client.py tests/unit/test_telemetry.py tests/unit/test_ports.py -q --no-cov` 全绿;`make lint`。 - **持续慢速 → `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`。 - **提交**: `feat: add OcrClient.parse_document with full governance`。
### T7 注册面与对齐工具 — [ ] 一次提交 ### 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`(无推理入口列表)。 - **文件**: `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`。 - **行为**: `_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; 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 仓库而不可达,如实记录「不可达,未执行」,**不编造通过**。
- **验证**: `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`。 - **提交**: `docs: register parse_document in telemetry schema and alignment targets`。
### T8 活体集成用例(`slow`,opt-in) — [ ] 一次提交 ### T8 活体集成用例(`slow`,opt-in) — [ ] 一次提交
- **文件**: `tests/integration/test_monkey_ocr_live.py`。 - **文件**: `tests/integration/test_monkey_ocr_document_live.py`(**新文件**,模块级 `pytestmark = pytest.mark.slow`,**不**带图片语料的 `skipif`)。
- **行为**: 测试内手写最小合法 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 的环境下证明坏文档未把熔断打开)。 - **行为**: 测试内手写最小合法 PDF 构造函数 `_minimal_pdf(text)`(5 个对象、**计算** xref 偏移、大号 Helvetica 文字,无第三方内容、无新依赖)。统一放在 `class TestLiveDocument`,三条用例,串行、单份小文档(服务与医院线共用,不压测):
- **验证**: 日常套件不跑(默认 `-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 掩盖。 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`。 - **提交**: `test: add live PDF document parsing checks`。
### T9 文档与版本 — [ ] 一次提交(bump 不得裸发,设计/CLAUDE.md §4.4.1) ### T9 文档与版本 — [ ] 一次提交(bump 不得裸发,CLAUDE.md §4.4.1)
- **性质**: 文档/版本任务,无行为红灯;证据 = `test_package` 的版本一致性用例 + 对版本串与方法名的 `grep` 输出。
- **文件与内容**: - **文件与内容**:
- `pyproject.toml` 与 `src/polygateway/__init__.py` 的版本 → `1.3.11`(`test_package` 钉两处一致)。 - `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 路径未经实测);配置指引(文档线独立 scope、`call_deadline_s ≤ lease_ttl_s`、sync-only 并发 1);关联 #34,后续 #35/#36。 - `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` 用法与能力表一行、方法数、文档线配置示例、服务限制;**安装命令的版本约束逐项核对**(CLAUDE.md §4.4.1 第 1 步,极易漏改)。 - `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__*`,`MAX_CONCURRENCY=2`、`RETRY__MAX_ATTEMPTS=2`、`TIMEOUT_S` 公式注释、`call_deadline_s` 键)。 - `.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 注记;`designs/2026-07-21-m3-ocr-design.md` §4 加勘误(「服务按内容处理」只验证图片,指向本设计 E3/E4/E10);`migrations/chsanalyzer.md` 加 CHSAnalyzer3 新消费路径与 H3 待确认项。 - `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 状态行则同步。 - `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`。 - **提交**: `docs: document parse_document and bump version to 1.3.11`。
### T10 验证与审查(合并前硬门)— [ ] 无代码提交(审查修复另提) ### T10 验证与审查(合并前硬门)— [ ] 验收任务,无独立红灯;审查修复另提
1. `make ci`(只读验证: `ruff format --check` + `ruff check` + `lint-imports` + 全套件)——输出重定向文件取退出码,**不接管道**。 1. `make ci`(只读验证: `ruff format --check` + `ruff check` + `lint-imports` + 全套件)——输出重定向文件取退出码,**不接管道**。
2. 覆盖率: 新增/改动模块行覆盖率 ≥ 80%,未覆盖行逐一说明。 2. 覆盖率: 新增/改动模块行覆盖率 ≥ 80%,未覆盖行逐一说明;对改动的三个模块运行 radon,**以实测为准**报告。
3. 派**全新上下文 verifier subagent**(`verification-before-completion`): 逐条对照设计 §4-§10 与本计划验收标准,只读核对测试证据(红灯日志存在且失败原因正确);任何声明须对应本会话工具输出。 3. 派**全新上下文 verifier subagent**(`verification-before-completion`): 逐条对照设计 §4-§10 与本计划验收标准,只读核对测试证据(红灯日志存在且失败原因正确);任何声明须对应本会话工具输出。
4. `requesting-code-review` 整分支审查(含 Codex `--fresh --wait`);逐条核验后修订,不盲从。 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` 原文;结果入最终报告。 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 延期语义)。 6. 最终报告 ≤300 行,写入 `research-wiki/reviews/`;向人类汇报**未验证项**(多行/多 span、嵌套 caption、旋转/批注页差分、线上 backend、CHSAnalyzer3 延期语义、真实 httpcore read-timeout 行为)。
7. **停在此处,等待人类的发布指令**(合并 main、打 tag、构建、上传、验证已发布、建 Release、关闭 #34 逐步执行,CLAUDE.md §4.4.1)。 7. **停在此处,等待人类的发布指令**(合并 main、打 tag、构建、上传、验证已发布、建 Release、关闭 #34 逐步执行,CLAUDE.md §4.4.1)。
## 4. 自审清单(写完计划后逐项过) ## 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 | | 设计每条决策有对应任务 | 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 点 | | 跨任务签名一致 | `parse_document_package` 返回三元组(T3)被 T5 `to_thread` 消费;`rejected_markers`(T4)被 T5 透传;`_document_transport()`(T6)是唯一 isinstance 点;四处结果联合类型(T6)已逐处列出 |
| 无占位语 | 无 TBD/TODO;关键接口已在 §2 给出实际签名 | | 无占位语 | 无 TBD/TODO;关键接口已在 §2 给出实际签名;时间/并发类测试的写法已固定 |
| 复杂度 | T3 已按函数拆分;T4/T5/T6 的新增分支各自 ≤ B 级,提交门会机械复核 | | 复杂度 | 只能「预计」;T3 已拆到 `_line_text/_span_text/_table_text/_page_index`;每次提交前 radon 实测 |
| 范围 | 不含 #35/#36 的改动;不含发布动作;不改 `parse_layout` 的校验口径 | | 证据完整性 | 每个行为任务写明红灯预期;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 样本前置 → 订正;⑨ 复杂度表述 → 改「预计」;⑩ 文件清单/文案兼容 → 订正。