|
|
|
@@ -0,0 +1,221 @@
|
|
|
|
|
---
|
|
|
|
|
type: plan
|
|
|
|
|
node_id: plan:2026-10-21-issue34-ocr-document
|
|
|
|
|
title: OCR 整份文档解析实现计划(#34)
|
|
|
|
|
date: 2026-10-21
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
# #34 OCR 整份文档解析实现计划
|
|
|
|
|
|
|
|
|
|
> 实现已获批准的设计 [2026-10-21-issue34-ocr-document-design](../designs/2026-10-21-issue34-ocr-document-design.md)(下称「设计」,章节号引自它)。
|
|
|
|
|
> 分支 `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. 跨任务关键接口(执行时以此为准)
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
# 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` 的校验口径 |
|