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";OcrLayoutElementdocstring 加「追加带默认值字段前须评估子类OcrDocumentElement(无默认字段会在导入期TypeError)」;OcrDocumentResult不继承OcrLayoutResult,docstring 写明原因;OcrDocumentResult.markdown的 docstring 写明「含悬空引用——库不外传图片」,OcrDocumentElementdocstring 写明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、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改为从新模块导入,行为不变;新模块不得反向导入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_packagedocstring 写明与 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:_sniff_media(document) == "unknown"→RequestRejectedError(无status_code,operation="parse"),不发 HTTP。- POST(传
self._rejected_markers)。 _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(不得让结果包资源检查改变错误分类)。await asyncio.to_thread(parse_document_package, content)。- 返回
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"动作,sourcetimeout_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,三条用例,串行、单份小文档(服务与医院线共用,不压测):test_parse_document_returns_pages_and_text: PDF 文字取POLYGATEWAY SYNTHETIC SAMPLE;断言 ≥1 页、page_sizes[0]均为正、"SYNTHETIC"出现在markdown.upper()与某个元素text.upper()中(正向断言,空串不能通过)。test_recognize_text_on_pdf_is_not_the_hallucination: 同一 PDF 的recognize_text:text.strip()非空且"single character"不在其中,并含"SYNTHETIC"。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 验证与审查(合并前硬门)— [ ] 验收任务,无独立红灯;审查修复另提
make ci(只读验证:ruff format --check+ruff check+lint-imports+ 全套件)——输出重定向文件取退出码,不接管道。- 覆盖率: 新增/改动模块行覆盖率 ≥ 80%,未覆盖行逐一说明;对改动的三个模块运行 radon,以实测为准报告。
- 派全新上下文 verifier subagent(
verification-before-completion): 逐条对照设计 §4-§10 与本计划验收标准,只读核对测试证据(红灯日志存在且失败原因正确);任何声明须对应本会话工具输出。 requesting-code-review整分支审查(含 Codex--fresh --wait);逐条核验后修订,不盲从。- 活体: 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原文;样本不存在则在报告中列为未验证项,不得声称已验证。 - 最终报告 ≤300 行,写入
research-wiki/reviews/;向人类汇报未验证项(多行/多 span、嵌套 caption、旋转/批注页差分、线上 backend、CHSAnalyzer3 延期语义、真实 httpcore read-timeout 行为)。 - 停在此处,等待人类的发布指令(合并 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 样本前置 → 订正;⑨ 复杂度表述 → 改「预计」;⑩ 文件清单/文案兼容 → 订正。