fb67b68440
Adds live-service and server-source evidence, structure contract, exception and resource boundaries, corrected breaker analysis, and the Codex review record.
288 lines
40 KiB
Markdown
288 lines
40 KiB
Markdown
---
|
||
type: design
|
||
node_id: design:2026-10-21-issue34-ocr-document-design
|
||
title: OCR 整份文档解析设计(#34)
|
||
date: 2026-10-21
|
||
---
|
||
|
||
# #34 OCR 整份文档解析设计
|
||
|
||
> 状态: **待人类审批**(公共 API + 端口新增,强制人类门)。已完成 Claude 自审与一轮独立 subagent 论证(§12);Codex 一轮审查已完成并逐条处置(§12 审查记录)。
|
||
> 基线: `25a90eb`(1.3.10);目标版本 **1.3.11**;分支 `fix/issue34-ocr-document`。
|
||
> 需求: [#34](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/34)(CHSAnalyzer3 把几百篇医学论文 PDF 整份转成带页码正文与表格)。
|
||
> 修订对象: [M3 OCR 设计](2026-07-21-m3-ocr-design.md) §4 "multipart 固定名 image.jpg,服务按内容处理"(该论断只在图片样本上验证过,见 §1 E3/E4)、ARCHITECTURE §7.10。
|
||
|
||
## 1. 问题与根因
|
||
|
||
issue 把症状拆成两条;**两条同根**: 库把 OCR 输入建模成"一张图 + 写死的标签",把输出建模成"只有框",**没有"文档"这个概念**——输入类型既不声明也不检测,输出不含内容。M3 设计 §4 的"服务按内容处理"是从 35 张图片样本外推到 PDF 的未验证假设。
|
||
|
||
以下事实 2026-10-21 在真实 MonkeyOCR 服务实测取得(样本为第三方 AI 生成论文,仅本地存证,**不入库**):
|
||
|
||
| # | 事实 | 设计影响 |
|
||
| --- | --- | --- |
|
||
| E1 | 服务按**扩展名**分派,仅 `.jpg/.jpeg/.png/.pdf`(大小写不敏感);其余(`.webp`/无扩展名)→ HTTP 500 `{"detail":"Parsing failed: 400: Unsupported file type…"}` | 上传标签必须落在这四个之内;服务把客户端错误包成 500 |
|
||
| E2 | 内容与扩展名不符(jpg 字节标 `doc.pdf`)→ HTTP 500 `Parsing failed: is no PDF` | **调用方声明类型会新增一个失败入口**,故类型由库按内容判定 |
|
||
| E3 | `/parse`: 同一 PDF 以 `image.jpg` 与 `doc.pdf` 上传,`_middle.json`/`_content_list.json`/`.md` **逐字相同**(只有 message 与包内文件名前缀不同)——文本型 2 页 PDF 与**无文字层的 2 页位图 PDF**(扫描件模拟,`pdftotext` 字符数 0)两种均验证; **旋转页、带批注页未测**;PNG/TIFF/BMP 字节标 `image.jpg` 亦成功(服务按内容解码);前缀 200 字节垃圾的 PDF 以 `doc.pdf` 上传仍被解析 | 问题一在 `/parse` 上**目前未观察到结果差异**,但不是保证(E10);非 PDF 沿用 `image.jpg` 才是零回归;前缀垃圾的 PDF 服务可解析,嗅探须容忍 |
|
||
| E4 | `/ocr/text`: PDF 字节标 `image.jpg` → HTTP 200 `success:true`,content=`"The image contains a single character: 1"`(**静默错误结果**);标 `doc.pdf` → 整份全文(无页码) | 问题一的真实危害在 `/ocr/text`,issue 未提及;须一并修 |
|
||
| E5 | 截断 PDF / 伪 `%PDF-` 头 / 乱码图 / 空文件 → 全部 **HTTP 500** `{"detail":"Parsing failed: <原因>"}` | 当前 ≥500 一律 `TransientError`,见 §6 |
|
||
| E6 | 结果包含 `*.md`、`*_middle.json`、`*_content_list.json`、三份 `*.pdf`;**含图/表的文档另有 `images/*.jpg`**(4 页含 4 图 ≈598KB;无图的 2 页文档没有该目录) | 包体随插图增长;只读取需要的成员,不解出图片 |
|
||
| E7 | `_content_list.json` 与 `_middle.json` 逐块 1:1(三样本 29/29、89/89、30/30),但 content_list **无 bbox**、type 词汇不同(`title→text+text_level`、`interline_equation→equation`),且**文本里 `$` 被转义成 `\$`**(134 个 text/title 块中 23 个) | 不用 content_list,见 §3 Q3 |
|
||
| E8 | middle 的 text/title 块恰 1 行 1 span,`content` 与 content_list 反转义后 134/134 一致;table 的 span 带 `html`;interline_equation 的 span 内容为裸 LaTeX;image 嵌套 `image_body` 仅含 `image_path`;嵌套 `*_caption/*_footnote` 子块**从未出现** | 内容可只从 middle 取得;caption/footnote 路径无真实样本 |
|
||
| E9 | 速度约 1.5 s/页(2 页 3.5s,4 页 6.4s;n=3、无争用,不是 SLA)。库内**无任何基于延迟的熔断/慢判定**(`grep latency` 在 breaker/admission/sources 无命中) | 长文档不会被"判慢",真正的风险是 §7 的超时被算成失败 |
|
||
| E10 | 服务源码(本机 `~/Code/MonkeyOCR`,`a4d853f`,版本 1.1.0 与样本 `_version_name` 一致)核对: ① 分派只看上传文件名后缀(白名单 `api/main.py:435-441`),`pdf` 走 `PymuDocDataset`、其余走 `ImageDataset`(`api/main.py:164-170`;后者先 `convert_to_pdf()`,`magic_pdf/data/dataset.py:231-235`)——**PDF 标成 `image.jpg` 走的是图片数据集分支**,E3 的"逐字相同"只在那份 2 页文本型 PDF 上验证,扫描件/旋转页/带批注页无证据; ② `/parse` 把一切异常(含 400)改写成 `500 "Parsing failed: …"`(`:501-503`),连 `Model not initialized` 也变成同前缀 | 支持嗅探(让 PDF 走它本该走的分支);**前缀 `Parsing failed:` 不能单独作为"坏输入"判据**,只有具体标记白名单可行 |
|
||
| E11 | `content_list` 是**展示格式**: 文本经 `markdown_utils` 转义 `* \` ~ $` 四个字符(`ocr_mkcontent.py:148`);医学论文里 `*`(显著性)与 `~` 比 `$` 更常见,污染面大于 E7 的 23/134 | 强化 Q3: 绝不读 content_list 取文字 |
|
||
| E12 | **服务端丢弃**脚注/页眉/页脚/页码/印章(默认版面模型 PP-DocLayout_plus-L 把这些标签映射到 `Abandon` → `discarded_blocks`,span `content` 全空,即**未被 OCR**);`figure_title` 映射为普通 `text` 块,故默认部署**不产生**嵌套 `*_caption/*_footnote` 子块(换 `doclayout_yolo` 才可能);`level` 恒为 1;`need_drop` 在该流水线硬编码为 False | 表格脚注(显著性/缩写说明)在服务端就已丢失,库找不回——写成"服务限制"告知下游;`level` 不加字段;嵌套子块仅作最小防御 |
|
||
| E13 | `/ocr/text` 对**无法解码的输入**返回 `success:true` + 幻觉文本: 乱码字节(`GARBAGE-NOT-AN-IMAGE`)得到与 E4 完全相同的 `"The image contains a single character: 1"`——即 E4 的本质是"服务把 PDF 当作无法解码的图"。另据源码,该端点内部异常会返回 **HTTP 200 + `success:false` + `content:""`**(`api/main.py:622-629`),而 `recognize_text` 只校验 `content` 为 str、不看 `success`(`monkey_ocr.py:240-247`)。后者**未经线上触发,仅源码推断** | 标签修正只能消除"PDF 被当图"这一类; 对无法解码输入的静默错误是服务行为,库侧要治需另行设计(§12 H2) |
|
||
| E14 | 服务并发模型: `/parse` 推理在 sync-only 后端下持有**全局 `asyncio.Lock`**(`api/main.py:183-189`);`supports_async` 取决于 chat 后端是否有 `async_batch_inference`,出厂默认 `lmdeploy` 为 sync-only(`model_configs.yaml`),**线上实际 backend 未知**(启动日志有 `async-capable`/`sync-only` 一行) | 若 sync-only: 每实例 `/parse` 全局串行,我们的长文档会**队头阻塞医院线**; `timeout_s` 须覆盖"排队+处理" |
|
||
|
||
## 2. 现状代码面
|
||
|
||
| 位置 | 现状 |
|
||
| --- | --- |
|
||
| `transports/monkey_ocr.py:207-210` | `_files()` 固定 `("image.jpg", …, "image/jpeg")`,`/ocr/text` 与 `/parse` 共用 |
|
||
| `monkey_ocr.py:136-144,147-166` | `_parse_block` 只取 type/bbox;`_parse_pages` 对非法 `page_idx` 回退枚举下标;bbox 含 CHS shim 专用的整数化退化校验 |
|
||
| `monkey_ocr.py:79-100` | `_classify_status`: ≥500/429→Transient、401/403→SourceDead、其余→RequestRejected |
|
||
| `types.py:1187-1250` | `OcrLayoutElement(type,bbox,page_index)`、`OcrLayoutResult`、`OcrLayoutTransportResult` |
|
||
| `ocr.py:625-628` | `_summarize` 用 `hasattr(result,"text")` 区分文本/版面——**对新结果类型是陷阱** |
|
||
| `ports.py:105-121` | `OcrTransport` 是 `@runtime_checkable`,加成员会让所有现有实现(测试替身、下游 fake)在 `isinstance` 上不合格 |
|
||
|
||
## 3. 备选方案对比
|
||
|
||
### Q1 公共面形态
|
||
|
||
| 方案 | 做法 | 权衡 |
|
||
| --- | --- | --- |
|
||
| **A(推荐)** | 新增 `OcrClient.parse_document` + `OcrDocumentPort`;新类型 `OcrDocumentElement(OcrLayoutElement)`(仅增 `text`)与 `OcrDocumentResult`;`parse_layout`/`recognize_text` 签名与返回类型不动 | 医院线零风险;文档线的内容解析故障不波及版面路径 |
|
||
| B | 给 `OcrLayoutElement` 追加 `text` 字段,`parse_layout` 同时返回内容 | 违反 issue "parse_layout 不动";`text: str\|None` 在一条路径上恒为 None;医院线耦合到新增的包成员解析,任一故障波及它 |
|
||
| C | 只加 `filename/media_type` 参数并把 ZIP 原样交给调用方 | 把校验、数值防御、阅读序整理全推给下游,违背库的价值;每个下游各写一份解析 |
|
||
|
||
### Q2 上传类型如何确定
|
||
|
||
| 方案 | 做法 | 权衡 |
|
||
| --- | --- | --- |
|
||
| **A(推荐)** | transport 按内容嗅探,判定顺序固定: ① 起始为已知图片魔数(JPEG/PNG/TIFF/BMP/GIF/WebP)→ 图片;② 否则前 1024 字节内含 `%PDF-` → PDF(Adobe 阅读器的头部容忍惯例,E3 实测服务亦可解析);③ 否则未知。PDF → `doc.pdf`/`application/pdf`,**图片与未知一律沿用 `image.jpg`/`image/jpeg`**。单点 `_upload_part()`,三个方法共用 | 图片魔数先判,含 XMP 元数据的图片不会因内嵌 `%PDF-` 字样被误判;非 PDF 路径字节级不变;顺带修 E4 |
|
||
| B | 调用方传 `media_type`/`filename`(**issue #34 原文提议**) | 声明与内容不符 → E2 的 500;若放在旧两方法上,仍走共享分类 → Transient → 重试并计熔断: 笔误变熔断事件。库本就能从内容判定,声明是冗余信息; 唯一得益场景(前缀垃圾 PDF)已被 A 的 ② 覆盖。**这是对 issue 提议的有意替换,列入 H5 由人类批准** |
|
||
| C | 嗅探全部图片格式并精确标注(png→`image.png`) | 零收益;webp/gif 等无对应扩展名反而要拒绝,把现在能成功的路径(E3)变成失败 |
|
||
|
||
三个方法对同一输入的结局(Codex 审查要求显式化):
|
||
|
||
| 输入 | `recognize_text` | `parse_layout` | `parse_document` |
|
||
| --- | --- | --- | --- |
|
||
| 正常 PDF | `doc.pdf`,整份全文(E4 修复) | `doc.pdf`(上传分支变更,见 §8) | `doc.pdf`,完整文档结果 |
|
||
| 前缀垃圾的 PDF | 同上 | 同上 | 同上 |
|
||
| 已知图片 / 其它可解码图片 | `image.jpg`(不变) | `image.jpg`(不变) | `image.jpg`,1 页结果 |
|
||
| 无法识别的字节(HTML 页面、乱码、空) | `image.jpg`(不变,服务给幻觉文本 E13) | `image.jpg`(不变,服务 500) | **本地 `RequestRejectedError`,不发 HTTP** |
|
||
|
||
### Q3 文字内容从哪里来
|
||
|
||
| 方案 | 做法 | 权衡 |
|
||
| --- | --- | --- |
|
||
| **A(推荐)** | **只读 `_middle.json`**(已是 bbox/type/页码/阅读序的来源),从 `lines[].spans[]` 取 `content`/`html`;`.md` 原文单独取 | 单一来源,无对位、无类型映射、无版本漂移故障面;不受 `\$` 转义污染 |
|
||
| B | `_content_list.json` 取文字 + middle 取 bbox,按页内顺序对位并严格校验(本设计初稿方案) | 1:1 是上游构造性成立但**无契约**;漂移时整份 `ResultInvalid`,几百篇批处理会"一批全挂";文本含 `\$` 需反转义,且 title 的 level 等字段样本中无信息量(21/21 均为 1) |
|
||
| C | 只用 content_list、不要 bbox | 不满足 issue 的"每块含 bbox" |
|
||
|
||
### Q4 确定性坏输入(E5)的分类
|
||
|
||
| 方案 | 做法 | 权衡 |
|
||
| --- | --- | --- |
|
||
| **A(推荐)** | **只在 `parse_document` 路径**: ① 本地魔数预检(既非 PDF 也非已知图片魔数 → 本地 `RequestRejectedError`,零 HTTP); ② 500 + `Parsing failed:` 且含已知确定性标记 → `RequestRejectedError`(保留 `status_code=500`)。共享 `_classify_status` 默认行为字节级不变 | 既有 `parse_layout`/`recognize_text` 的行为零变更;未知 500 仍 Transient(安全方向) |
|
||
| B | 直接改共享 `_classify_status`,所有 OCR 方法受益 | 改变医院线对坏图的重试/熔断行为,属公共行为变更,应单独走设计 → **另开 issue**(待人类决定) |
|
||
| C | 不处理 | 批处理后果见 §6 |
|
||
|
||
### Q5 长文档超时
|
||
|
||
| 方案 | 做法 | 权衡 |
|
||
| --- | --- | --- |
|
||
| **A(推荐)** | 不加逐调用 HTTP 超时;文档线用**独立 scope**(如 `OCR_DOC`)指向同一服务 URL,自带 `TIMEOUT_S`/重试/熔断阈值;文档 + 测试钉死"无延迟判慢" | 与现有租约不变式一致 |
|
||
| B | `parse_document(timeout_s=…)` 逐调用超时 | `RateLimiter.try_acquire(source_key, est_tokens)`(`ports.py:137`)无 TTL 参数,租约长度是构造期常量,逐调用放大超时会让请求活过租约 → 并发配额被突破 |
|
||
|
||
## 4. 公共 API(冻结面,人类门审这里)
|
||
|
||
### 4.1 类型(`types.py`,全部 frozen dataclass)
|
||
|
||
| 类型 | 字段 | 说明 |
|
||
| --- | --- | --- |
|
||
| `OcrDocumentElement(OcrLayoutElement)` | 继承 `type/bbox/page_index`;新增 `text: str` | `text` 随 type: `text`/`title` → 纯文本(行内公式保留 `$…$`);`interline_equation` → 裸 LaTeX;`table` → HTML 表体;`image` → `""`;嵌套的非 body 子块(`*_caption`/`*_footnote`)作为**独立元素**输出,type 取子块类型 |
|
||
| `OcrDocumentResult` | `elements: list[OcrDocumentElement]`、`page_sizes: list[tuple[float,float]]`、`markdown: str` + 溯源五件 `source_name/usage/latency_ms/call_id/raw` + `call_stats=None` | **独立定义,不继承** `OcrLayoutResult`: 父类末尾 `call_stats` 带默认值,子类无默认字段 `markdown` 会在导入期 `TypeError` |
|
||
| `OcrDocumentTransportResult` | `elements`、`page_sizes`、`markdown`、`raw` | transport → client 内部产物 |
|
||
|
||
约束与理由:
|
||
|
||
| 点 | 决定 |
|
||
| --- | --- |
|
||
| 为何继承 | 文档元素"是"版面元素 + 内容;按 bbox 裁剪的下游函数可同时吃两种。代价: dataclass `__eq__` 要求同类,`OcrDocumentElement != OcrLayoutElement`;`OcrLayoutElement` docstring 加警示"追加带默认值字段前须评估子类" |
|
||
| 为何不加 `level`/`captions`/`footnotes` | 样本中 21 个 title 全是 level 1(无信息量);caption/footnote 在全部样本里恒为空——为从未见过的路径加公共字段不可验证。嵌套子块独立成元素的方案零新增字段,且服务将来开始关联 caption 时不丢信息 |
|
||
| `markdown` | 整篇产物,属 result 不属元素。**含悬空图片引用** ``(库不外传图片),docstring 注明 |
|
||
| 阅读序 | 与服务 `para_blocks` 顺序一致,页内按页序;嵌套子块紧随其父块 |
|
||
|
||
### 4.2 端口(`ports.py`)
|
||
|
||
| 端口 | 签名 | 说明 |
|
||
| --- | --- | --- |
|
||
| `OcrDocumentPort`(新,`runtime_checkable`) | `async def parse_document(self, document: bytes) -> OcrDocumentResult` | 对应 `/parse` 整份文档 |
|
||
| `OcrDocumentTransport`(新,`runtime_checkable`) | `async def parse_document(self, *, document: bytes, source: SourceConfig, call_id: str) -> OcrDocumentTransportResult` | **不往 `OcrTransport` 加成员**(会破坏全部现有实现的 `isinstance`);`MonkeyOcrTransport` 同时实现两者 |
|
||
|
||
`OcrClient.parse_document(document, *, session_id=None, parent_call_id=None, tenant_id=None, meta=None, call_deadline_s=None)`: 签名与 `parse_layout` 对称。入口校验先于 `_CallContext`(同 `_call` 既有顺序): `bytes` 非空、`validate_caller_dimensions`、`ensure_call_deadline`、**transport 须满足 `OcrDocumentTransport`,否则 `TypeError`**(装配错误当场报)。
|
||
|
||
### 4.3 其它公共面
|
||
|
||
| 项 | 变更 |
|
||
| --- | --- |
|
||
| `CallOperation` / `CALL_OPERATIONS`(`types.py:279,285`) | 新增 `"parse_document"`。`CALL_OPERATIONS` 目前**无任何读者**,新增测试钉死 `get_args(CallOperation) == CALL_OPERATIONS` |
|
||
| 包根导出 | `OcrDocumentElement`、`OcrDocumentResult` |
|
||
| 遥测 | 同一张 `llm_calls`,`operation="parse_document"`;占位仍为 `<ocr:document image_bytes=N>`(**不改字面**: 改名会破坏既有分组与 `test_telemetry.py` 对格式的锁定);response 摘要 `<document elements=N pages=M md_chars=K>`,**永不写入正文**;无新列、无 DDL 变更 |
|
||
| 配置 | **无新键**。文档线独立 scope 是部署方式,不是新配置(`.env.example` 加示例段) |
|
||
|
||
## 5. Transport 行为(`transports/monkey_ocr.py`)
|
||
|
||
| 关切 | 决策 |
|
||
| --- | --- |
|
||
| 上传标签 | `_files()` 改为 `_upload_part(data)`,按 §3 Q2 的固定顺序嗅探: PDF → `("doc.pdf", data, "application/pdf")`,其余 → 原样 `("image.jpg", data, "image/jpeg")`。三个方法共用。包内文件名前缀由 `image_` 变 `doc_`,解包靠后缀 `endswith("_middle.json")`(`monkey_ocr.py:179`),不受影响 |
|
||
| 本地预检(仅 document) | 嗅探结果为"未知"(HTML 付费墙页面、乱码、空串)→ `RequestRejectedError`(**不带 status_code**,服务未响应),不发 HTTP。已知图片魔数: JPEG `FFD8FF`、PNG、TIFF(`II*\0`/`MM\0*`)、BMP(`BM`)、GIF、WebP(`RIFF…WEBP`);魔数表之外的可解码图片格式在 document 路径会被拒,需要时由调用方走 `parse_layout` |
|
||
| 两段协议 | 与 `parse_layout` 同: POST `/parse` → 校验 `success is True` + `download_url` → GET → ZIP。document 路径的 GET 改**流式读取并累计字节**,整包上限 256 MiB(模块常量),超限 → `ResultInvalidError`;HTTP 状态错误须先 `aread()` 再 `raise_for_status()`,保持"响应体摘要随所有分支"的 issue #10 口径 |
|
||
| 解析卸载 | ZIP 解包 + JSON 解析 + 结构校验是 bytes→结果的**纯函数**,在 `asyncio.to_thread` 中执行: 同步解析大对象会占住事件循环,使取消与 `call_deadline_s`(基于 `asyncio.timeout`,`deadline.py:69-79`)无法及时生效。取消后线程自然收尾、结果丢弃。`parse_layout` 不改 |
|
||
| 外部数据异常边界 | 纯函数内**统一**把外部字节引起的已知异常转 `ResultInvalidError`: 函数级 `ValueError`(含 `JSONDecodeError`/`UnicodeDecodeError`)、`TypeError`、`KeyError`、`OverflowError`(巨大整数转 float,实测 `float(10**400)` 抛它)、`RecursionError`(深嵌套 JSON)、`zipfile.BadZipFile`/`LargeZipFile`;**仅包在 `archive.open/read` 外**的窄 `try`: `RuntimeError`(标准库对加密成员抛它)、`NotImplementedError`(未知压缩方法)、`zlib.error`、`EOFError`。取消(`CancelledError`)不在其内,原样穿透。理由: `ocr.py:303-321` 的 `_call` 只接 `PolyGatewayError`,漏出的原生异常既不写终态遥测行,又会让持有的半开探针不被归还 |
|
||
| ZIP 成员与体积 | 读 `*_middle.json`(取第一个,同 layout);`.md` 按 `middle 名去掉 "_middle.json" + ".md"` 精确配对(不取"第一个 .md");缺失 → `ResultInvalidError`。读取前检查 `ZipInfo.file_size`: middle ≤ 64 MiB、md ≤ 16 MiB,超限 → `ResultInvalidError`。**不用 `extractall`**,不解出 `images/`;成员数由整包上限间接约束。仅 document 路径;`parse_layout` 不改(医院线零变更) |
|
||
| 文字提取 | 块内 `lines[].spans[]`: 取 `content`;`inline_equation` 类型 span 包 `$…$`(上游惯例,**样本中从未出现**);行内 span 按 `""` 拼、行间按 `"\n"` 拼。**样本全是 1 行 1 span**,多行/多 span 路径无真实样本,仅构造 fixture 测试,文档标"未经实测" |
|
||
| 其它页内容 | `discarded_blocks` 忽略(E12: 服务端已丢弃脚注/页眉页脚/页码,span 内容全空,库无物可取);`need_drop` 不读取、不失败(该流水线硬编码为 False) |
|
||
| 嵌套子块 | 默认部署不产生(E12);仅作最小防御: 非 `*_body` 的嵌套子块若有文字,作为独立元素紧随父块输出,**不静默丢弃**。该路径无真实样本,仅构造 fixture 测试 |
|
||
| `raw` | `{"success": True, "download_url": …}`,与 `parse_layout` 同形 |
|
||
|
||
### 5.1 结构契约(缺失 / 空值 / 类型错误 / 未知类型逐项裁决)
|
||
|
||
架构要求严格区分合法"无内容"与调用失败(`ARCHITECTURE.md:427,672`)。下表是实现与测试的唯一依据。
|
||
|
||
| 情形 | 处理 |
|
||
| --- | --- |
|
||
| `pdf_info` 缺失或非 list | `ResultInvalidError` |
|
||
| `pdf_info == []`(零页) | `ResultInvalidError`: 成功解析的文档至少 1 页(与 layout 路径"elements 空合法"不同,差异写入 docstring) |
|
||
| 页非 dict / `page_size` 非 2 个正有限数 | `ResultInvalidError`(复用 `_parse_page_size`) |
|
||
| `page_idx` 非 int、为 bool、为负、或 ≠ 页在列表中的位置 | `ResultInvalidError`(layout 路径的"回退枚举"会使 `page_sizes` 按 `page_index` 索引的契约静默错位) |
|
||
| `para_blocks` 缺失 | 合法空页(空白页 / 无可识别块);非 list → `ResultInvalidError` |
|
||
| 块非 dict、`type` 缺失/非 str/空白 | `ResultInvalidError` |
|
||
| `bbox` | 须为长度 4 的有限数值且 `x1 ≤ x2`、`y1 ≤ y2`(允许零宽/零高,**不做**整数化退化校验——那条专为 CHS shim 的 `int()` 裁剪);反序/非有限 → `ResultInvalidError`。坐标系与 `page_sizes` 同为服务原生坐标,页码从 0 起;越界框不检查、不裁剪(图内语义归调用方,`ARCHITECTURE.md:250`) |
|
||
| 含 `lines` 的块(text/title/interline_equation/未知类型) | `lines` 须 list,每行 dict 且 `spans` 为 list,每 span 为 dict 且 `content` 为 str;违反 → `ResultInvalidError`;`lines == []` → `text=""` |
|
||
| table | 嵌套 `table_body` 的 span 取 `html`,无则取 `latex`(上游有该分支);二者皆无 → `ResultInvalidError`(表格无内容不静默为空串) |
|
||
| image | 嵌套 `image_body` → `text=""` |
|
||
| 未知类型且既无 `lines` 也无 `blocks` | `text=""`,`type` 原样保留(开放字符串) |
|
||
| `.md` | 成员缺失 → `ResultInvalidError`;按 `utf-8-sig` 解码(吞 BOM),失败 → `ResultInvalidError`;空串合法 |
|
||
|
||
## 6. 错误分类与批处理后果
|
||
|
||
| 失败 | 分类 | 治理结果 |
|
||
| --- | --- | --- |
|
||
| 上传前: 非 bytes/空 | `TypeError`/`ValueError` | 统计边界外(同 `_call` 既有) |
|
||
| 嗅探为"未知"(§5) | `RequestRejectedError`(无 status) | 不重试、不换源;仅归还探针,**不计熔断** |
|
||
| 500 + `Parsing failed:` + 已知标记 | `RequestRejectedError`(`status_code=500`) | 不重试;熔断**记成功**且不计窗口(`_gate_on_terminal`: `exc.status_code is not None`);不喂选源器 |
|
||
| 429 | `TransientError`(`status_code=429`) | 照扣重试预算、喂选源器失败; **熔断两通道均不计**(`memory/breaker.py:235-244`: `rate_limited` 是背压不是故障) |
|
||
| 其余 5xx、超时、网络错误 | `TransientError` | 重试 + 计熔断(现状) |
|
||
| `CallDeadlineExceeded`(整体期限到期) / 外部取消 | 专用异常 / `CancelledError` | **不是**源故障: 不计熔断; 在飞探针归还; 终态遥测行照写(`ocr.py:303-321`)。期限不会被混入 `TransientError` |
|
||
| 401/403 | `SourceDeadError` | 现状 |
|
||
| 其余 4xx、`success!=true`(附 200) | `RequestRejectedError` | 现状 |
|
||
| ZIP 坏 / 加密或未知压缩成员 / middle 或 md 缺失 / 超限 / 结构异常(§5.1)/ `page_idx` 错位 / 零页 | `ResultInvalidError` | 不重试;熔断记成功不计窗口(`_gate_on_terminal`: `ocr.py:493-499`) |
|
||
|
||
已知标记(模块常量,小写子串匹配,取 E5 实测): `failed to open stream`、`cannot open empty stream`、`cannot save with zero pages`、`is no pdf`。**不含** `unsupported file type`: 嗅探后它只可能由服务契约变化或我方标签 bug 引起,应响亮失败并计熔断。`MonkeyOcrTransport` 构造参数可覆盖该元组,不新增 `SourceConfig` 字段或环境变量键。
|
||
|
||
**为什么 Q4 不能不做**(读代码确认的机制链): ① 熔断按 `source_name` 计数(内存 `memory/breaker.py:56-61`;Redis 键 `pgw:gate:{scope}:{source}`,`backends/redis/breaker.py:308-311`);② 有效阈值 = `max(配置值, 源并发×2)`(`config.py:503-508`);③ 连续失败通道仅在**窗口无充分健康证据**时开路——"充分健康证据"= 窗口样本 ≥ `min_calls`(默认 10)**且**失败率 < `fail_rate`(默认 0.6)(`memory/breaker.py:84-90`、开路判据 `:252`),故样本不足 **或** 失败率 ≥ 0.6 时连败即开路。每份文档约 15 秒、并发 2,窗口凑不够 10 个样本,抑制不生效;④ 一份坏 PDF 重试 `max_attempts` 次即可在低并发配置下开路,冷却内同 scope 的好文档被 `fail_fast` 拒绝。影响范围是**同 scope**: 默认 memory 后端是进程内,只有 Redis 熔断后端加相同 scope 才跨进程(是否波及医院线取决于部署)。⑤ 下游语义: `migrations/chsanalyzer.md:72` 记录 CHSAnalyzer 把库侧"服务不可用"一族按 arq 延期重投且不耗业务失败预算——**若 CHSAnalyzer3 沿用,永久损坏的 PDF 会被无限延期。此点未向下游证实,需人类确认。**
|
||
|
||
白名单的脆弱性(诚实声明): 标记是 PyMuPDF 的界面文案,随服务版本漂移; 服务把所有异常都包成 `Parsing failed:`,若把磁盘满等真故障也包成同样文案会被误判为坏输入。**误判代价是"该文档失败而非延期"(下游重跑失败集合即可),反向代价是熔断误开**——取前者。
|
||
|
||
## 7. 长文档与资源(issue 要求 4)
|
||
|
||
| 结论 | 依据 |
|
||
| --- | --- |
|
||
| 默认值与熔断**不会把长文档判为慢** | 无延迟型判定(§1 E9);选源器只数成败(`sources.py:61-64`);`StallClock` 只计非生产性等待(`ocr.py:343-344`) |
|
||
| 把"慢"变成失败的路径有两条,性质不同 | ① httpx 超时: `monkey_ocr.py:63-68` → `TransientError`(`__cause__` 为 `TimeoutException`)→ 重试 + 计熔断; ② `call_deadline_s` 到期 → `CallDeadlineExceeded`,**不计熔断**(§6) |
|
||
| **`timeout_s` 不是总耗时上界**(Codex 审查纠正) | `httpx.Timeout(source.timeout_s)` 是**逐次网络操作**的超时(`httpcore` 每次读单独计时): POST 读超时≈服务处理时长上限(服务处理期间无字节),但 GET 下载只要持续有分块到达,总时长可超过任意多个 `timeout_s`。故初稿"最坏约 2×`timeout_s`"不成立,**不能据此推出租约安全** |
|
||
| 租约与探针租约的真实关系 | `config.py:338-345` 只守卫 `timeout_s ≤ lease_ttl_s`(默认 1500);限流租约到期即被移除(`memory/limiter.py:98-103`),探针租约到期可被接管(`memory/breaker.py:161-163`,派生 TTL 见 `config.py:509-518`)。**硬保证只能来自 `call_deadline_s`**: 文档线应配 `call_deadline_s ≤ lease_ttl_s`,使任何一次尝试都不会活过租约; 守卫改造(跨 scope 配置语义)**不在本次**,另列 follow-up |
|
||
| 配置口径(写入 `.env.example` 与 README) | 文档线独立 scope + `MAX_CONCURRENCY=2`(满足"并发不超过 2";sync-only 见下行)+ `RETRY__MAX_ATTEMPTS=2` + `timeout_s ≥ 最大页数×1.5s×余量`(共用 GPU 争用余量取 5 倍以上)+ **`call_deadline_s ≤ lease_ttl_s` 给单份封顶**。有效熔断阈值 `max(配置, 并发×2)=4` > 单份坏文档的重试次数 |
|
||
| 重试放大 | POST 成功(服务已算完)而 GET 失败时,重试**重新 POST 整份文档重算**; 超时重试同理,且服务端是否因断连取消计算无证据——假定不取消 |
|
||
| 无页数预检 | 库不依赖 PDF 库,发送前不知页数; 300 页文档会占满超时被重投。页数过滤是下游职责 |
|
||
| 内存 | document 路径 GET 改流式并累计字节,**整包上限 256 MiB**(§5);只解必要成员,成员再受声明大小上限约束。httpx 非流式 `get` 会先 `aread()` 整包(`httpx/_client.py:1635-1637`),这是改流式的原因; `parse_layout` 不改 |
|
||
| 服务端磁盘 | 每次调用在服务 `/app/tmp` 与 `/static` 留产物,几百篇约几百 MB 到 GB 级——**告知服务运维**(库不管理) |
|
||
| 并发上限的进程边界 | `MAX_CONCURRENCY` 默认进程内; 多 worker 共享上限需 `PGW_LIMITER_BACKEND=redis` 且同 scope |
|
||
| **服务端串行与队头阻塞(E14)** | 若线上为 sync-only: ① 每实例 `/parse` 全局串行,"并发 2"不提速,第二份文档在服务端排队 → `timeout_s` 须覆盖"排队+处理"≈ 并发数×单份耗时×余量; ② 一份 15 页文档约 22 秒的队头阻塞可能把医院线(`table_locator`)请求推到它们自己的 `timeout_s`,触发**它们**的 Transient 与熔断——"我们的长文档误伤他们"比"他们的判定误伤我们"更现实; ③ 7867 实例与 embedding 服务同卡(部署记录),重负载互相影响,不在库治理范围。**须向运维确认 backend**(启动日志 `model_manager.py:37-38`)。指引: sync-only 时文档线每实例并发 1、错峰运行、页数过滤在调用方 |
|
||
| 服务限制(告知下游) | 脚注/页眉页脚/页码在服务端即被丢弃(E12): 论文表格脚注的显著性与缩写说明不会出现在结果里; `markdown`/`elements` 均无此内容。库不能也不应补偿 |
|
||
|
||
## 8. 旧版行为审计
|
||
|
||
| 行为 | 裁决 |
|
||
| --- | --- |
|
||
| `_files()` 固定 `image.jpg` | **替换**: 嗅探为 PDF → `doc.pdf`; 其余**保留**(字节级不变) |
|
||
| `parse_layout` 收到 PDF | **有意改变上传分支**(`ImageDataset` → `PymuDocDataset`,E10)。差分证据: 文本型与位图型 2 页 PDF 结果逐字相同(E3); **旋转页、带批注页未测,不构成保证**。该路径无已知消费者(医院线只传图片,CHSAnalyzer3 走新方法),故列 H6 由人类批准,而不是声称"零风险" |
|
||
| `recognize_text` 收到 PDF | **修复**: 静默错误结果(E4)变为整份全文; 文档注明"不承诺 PDF 语义,请用 `parse_document`" |
|
||
| `parse_layout` 的 bbox 整数化退化校验 / `page_idx` 回退 / 无 ZIP 体积上限 | **保留不动**(医院线零变更); document 路径各自更严/更宽,差异写入 docstring |
|
||
| `_classify_status` 共享分类 | **保留**(默认参数不变) |
|
||
| `OcrTransport` 协议 | **保留**,不加成员 |
|
||
| `check_health`、重试/选源/限流/熔断/遥测编排 | **保留**: `parse_document` 复用同一 `_call/_run/_attempt` 循环,只扩 `_OcrKind` 与 `_invoke` 分派 |
|
||
|
||
## 9. 非功能维度
|
||
|
||
| 维度 | 回答 |
|
||
| --- | --- |
|
||
| 并发与取消 | 无新共享可变状态; `CancelledError` 路径与 `parse_layout` 一致(退避 sleep/限流等待/两段 HTTP 均可取消,permit 在 `finally` settle+release,探针取消归还); **新增**的 ZIP 解析在 `to_thread` 中,取消时立即返回(线程自然收尾、结果丢弃),不会像同步解析那样占住事件循环使取消/期限失效。`call_deadline_s` 到期会取消在飞请求,服务端是否继续计算未知 |
|
||
| 降级方向 | 准入阶段(选源/限流/熔断)后端故障 → 报错不放行(铁律,复用既有 `SourceAdmission`); 已发出请求之后的记账写回(`_record_quietly`,`ocr.py:511-517`)与遥测写失败 → warning 降级。**不能**把"后端故障一律上抛"理解为所有阶段 |
|
||
| 幂等与重复 | 只读幂等,重放安全; 代价见 §7"重试放大" |
|
||
| 持久化与原子性 | 库内无持久化; ZIP 解包在内存(`BytesIO`),不落盘 |
|
||
| 零业务假设 | 无医学/论文词汇进入库代码与 fixtures; `type` 仍为开放字符串 |
|
||
|
||
## 10. 测试策略
|
||
|
||
fixtures 一律**合成**(`tests/unit/test_monkey_ocr.py` 的 `_zip_bytes`/`_page` 风格上扩一个 document builder),**不提交任何真实 ZIP**(第三方内容许可不明; 医疗数据不入库,同 M3 设计 §11)。
|
||
|
||
| 层 | 内容(每条先红后绿) |
|
||
| --- | --- |
|
||
| unit/transport | 嗅探: 纯 PDF、前缀垃圾的 PDF、起始为图片魔数但内含 `%PDF-` 字样的图片(须判图片)→ 各自标签,非 PDF 逐字 `image.jpg`/`image/jpeg`,**三个方法各钉一次**(`test_multipart_shape` 现有断言保持成立); 文档解析: title/text(含 `$X$`)/interline_equation/带 nested `table_body` 的 table/带 `image_body` 的 image 的 text 取值; 垃圾 `content_list.json` 与 `images/x.jpg` 并存时输出与其无关; nested caption/footnote 成独立元素(标未经实测); `inline_equation` 包 `$`; 多行/多 span 拼接; `markdown` 精确配对与缺失; 体积上限; §5.1 契约表**逐行一条用例**(零页、`page_idx` 为 bool/负/错位、`para_blocks` 缺失=空页、非 list、块无 type、反序/非有限 bbox、零宽 bbox 合法、`lines` 各层类型错误、table 无 html/latex、未知类型、`.md` 缺失/非法编码/BOM/空串); `discarded_blocks` 不进结果 |
|
||
| unit/资源与异常边界 | 加密成员、未知压缩方法、`10**400` 整数、深嵌套 JSON、CRC 损坏 → 全部 `ResultInvalidError`(**不漏出原生异常**),并断言经 `OcrClient` 后终态遥测行已写、半开探针已归还; 整包流式累计超限(大图片包 + 小 middle/md)→ ResultInvalid; 声明大小超限成员 → ResultInvalid; 解析期间取消 → 立即 `CancelledError` 穿透(用阻塞式解析桩验证事件循环未被占住) |
|
||
| unit/分类 | 魔数预检(HTML 页面、空串、未知魔数); 四个已知标记 → `RequestRejectedError(status_code=500)`; 未知 500 仍 Transient; `unsupported file type` 仍 Transient; 默认 `_classify_status` 行为不变(现有 `test_http_status` 保持) |
|
||
| unit/client | 桩 transport 走完整治理循环; `isinstance` 缺 `OcrDocumentTransport` → `TypeError`; **无延迟判慢**: 在成功尝试内推进远大于 `timeout_s` 的时钟,断言成功、熔断 CLOSED、`fails==0`(**含"熔断已有失败计数"与半开探针两种前置状态**,不只测初始 CLOSED); 对照: 超时 Transient → 计失败 → 重试; **持续慢速下载**(每个分块都在 read 超时内、总时长超过 `call_deadline_s`)→ `CallDeadlineExceeded` 而非 Transient,且不计熔断; 429 → 照扣重试预算但熔断窗口不变; ResultInvalid/本地预检不计熔断窗口; 取消穿透且探针归还; 遥测行 `operation="parse_document"`、占位字面量、摘要不含正文; `_summarize` 三类结果各一条(新结果必须显式区分,防止静默套用 layout 摘要而漏记 `md_chars`); 终态行; 调用方维度(`tenant_id`/`meta`)与 `reasoning_applies=False` |
|
||
| unit/契约 | `get_args(CallOperation) == CALL_OPERATIONS`; `test_ports.py` 新增 Protocol 的 `runtime_checkable` 用例,并断言 `OcrTransport` 成员集合**未变** |
|
||
| integration(`slow`,opt-in,串行) | 手写最小合法 PDF(无第三方内容)打真实服务: `parse_document` 返回 ≥1 页、元素均有 `text` 类型; 同一 PDF 的 `recognize_text` 不再返回 E4 的幻觉文本; 损坏 PDF → `RequestRejectedError` 且熔断仍 CLOSED。成败取决于外部服务当下状态,按 CLAUDE.md §4.6 标 `slow`,不进日常套件 |
|
||
|
||
## 11. 落地时需同步的位置(grep 全引用面所得)
|
||
|
||
| 类别 | 位置 |
|
||
| --- | --- |
|
||
| 代码 | `types.py`(Literal/常量/新类型)、`ports.py`、`ocr.py`(`_OcrKind`/`_AttemptOutcome`/`_invoke`/`_emit`/`_summarize` 签名)、`transports/monkey_ocr.py`、`__init__.py`(导出 + `__version__`)、`pyproject.toml` |
|
||
| 校验工具 | `tools/check_wiki_alignment.py:37-46`(`_SIGNATURE_TARGETS` 加 `OcrClient.parse_document`) |
|
||
| wiki 注册 | `research-wiki/schemas/llm-calls.md`(operation 取值)、`metrics/ocr-call-success.md`、`metrics/call-telemetry-coverage.md`(无推理入口列表) |
|
||
| 文档 | `README.md`(方法数、能力表、安装版本约束)、`CHANGELOG.md`、`.env.example`(文档线 scope 示例)、`ARCHITECTURE.md` §5.1/§7.10/D9 注记、M3 设计 §4 勘误(指向本设计 E3/E4)、`migrations/chsanalyzer.md`(CHSAnalyzer3 新消费路径) |
|
||
| 测试 | `test_telemetry.py` 的 operation 参数化、`test_ports.py`、`test_monkey_ocr.py`、`test_ocr_client.py`、`tests/integration/test_monkey_ocr_live.py` |
|
||
| 无需迁移 | `llm_calls.operation` 列为 `TEXT` 无 CHECK(`telemetry/schema.py`),新值不需 DDL; 外部按 operation 分组的看板会自然多一组 |
|
||
|
||
CLAUDE.md §4.4.1 发布流程按其清单执行(README 在构建前改完; 1.3.11 与历史把新增公共方法放 patch 的惯例一致)。
|
||
|
||
## 12. 评审与待人类拍板
|
||
|
||
**已做**: Claude 自审; 一轮独立 subagent(全新上下文、只读)对 Q1-Q5 与遥测/引用面逐项论证,并核对本机 MonkeyOCR 源码(E10-E14,关键行已由我对源码与线上各复核一次)。**采纳其修正**: 弃初稿"content_list+middle 对位"(Q3,以 `\$` 污染 23/134 为据)、砍 `level/captions/footnotes`(Q1)、`OcrDocumentTransport` 另起 Protocol 而非扩 `OcrTransport`(§4.2)、document 路径的 `page_idx` 严格与 bbox 宽松(§5)、确定性 500 只做新路径窄化(Q4)、ZIP 体积上限、`_summarize` 改法。**更正我方事实**: 初稿称"结果包无 `images/`",实为含图文档才有(E6)。**Codex 独立审**: 一轮已完成,初审结论"否——先修正再审批";逐条核验后修订,见文末"审查记录"。
|
||
|
||
**需要人类决定**:
|
||
|
||
| # | 决策 | 推荐 |
|
||
| --- | --- | --- |
|
||
| H1 | §4 公共面(新方法 + 新 Protocol + 继承式元素、`text` 唯一新增字段、嵌套子块独立成元素) | 批准 |
|
||
| H2 | 既有端点的分类/校验问题: 本设计只在新路径窄化; 以下两项属**医院线既有行为变更**,另开 issue 供评估: (a) 共享 `_classify_status` 对确定性坏输入 500 仍归 Transient; (b) `recognize_text` 不校验 `success`(服务内部异常时返回 200+`success:false`+空文本,被当"合法无文字"且熔断记成功,E13,源码推断未线上复现) | 同意,并授权我建这两个 issue |
|
||
| H5 | **需求替换**: issue #34 原文提议 `parse_document(data, *, filename 或 media_type)` 由调用方声明类型; 本设计改为库按内容嗅探、**不提供声明参数**(理由见 §3 Q2: 冗余信息、笔误会变熔断事件、前缀垃圾 PDF 已被嗅探覆盖) | 批准替换; 若你更想要显式参数,代价是新增公共参数 + 与嗅探结果不符时的拒绝规则 |
|
||
| H6 | **有意改变既有方法的上传分支**: `parse_layout(PDF)` 与 `recognize_text(PDF)` 的 PDF 将走 `PymuDocDataset`/`doc.pdf`(前者结果在已测样本上逐字相同,旋转/批注页未测;后者是修复 E4) | 批准 |
|
||
| H3 | CHSAnalyzer3 是否把"服务不可用"一族按延期重投处理(§6 ⑤)——决定 `RequestRejectedError` 路径是否必要地"关键" | 请确认; 不影响实现,影响下游使用指引 |
|
||
| H4 | 实现完成后的发布(合并 main、tag、构建、上传 registry、建 Release、关闭 #34)**逐步等你明确指令**,不随实现自动执行 | 同意 |
|
||
|
||
## 审查记录
|
||
|
||
Codex 一轮独立只读审查(上游无法读取 Gitea issue,需求判断基于设计转述)。结论: **否,先修正**。逐条核验与处置:
|
||
|
||
| # | Codex 意见 | 核验 | 处置 |
|
||
| --- | --- | --- | --- |
|
||
| 1 阻断 | "2×`timeout_s`"不是总耗时上界,租约安全结论失效 | **属实**: httpcore 逐次读计时,GET 持续分块可超任意多个 `timeout_s`; 租约/探针租约到期语义见 `memory/limiter.py:98-103`、`memory/breaker.py:161-163` | §7 删除\"2×即安全\",改为硬保证只来自 `call_deadline_s ≤ lease_ttl_s`; 测试加慢速持续下载 |
|
||
| 2 高 | 调用方声明类型被自动嗅探替换,理由与 Q4 自洽性不足; 前缀垃圾 PDF 描述矛盾; \"1024 字节\"依据未核 | **部分属实**: 替换确属需求变更,须显式批准; 前缀垃圾 PDF 服务**实测可解析**,故改嗅探为\"图片魔数优先 → 前 1KB 内 `%PDF-`\",该场景不再被拒; 1024 字节来自 Adobe 阅读器的头部容忍惯例,不是 ISO 32000 正文,已改述为兼容性描述 | §3 Q2 重写 + 三方法结局表; 新增 H5 |
|
||
| 3 高 | `parse_layout(PDF)`\"零风险/不变\"与 E10 矛盾 | **属实**: 分支确实变了。补做位图型 PDF 差分(逐字相同); 旋转/批注页仍未测 | §8 改述\"有意改变\",新增 H6 |
|
||
| 4 高 | 成员大小限制不限整包内存; 同步解析使取消/期限失效 | **属实**(`httpx/_client.py:1635-1637` 非流式先 `aread()`; `deadline.py` 基于 `asyncio.timeout` 无法打断同步代码) | §5: document 路径流式 + 整包 256 MiB 上限 + `to_thread` 卸载; 测试加解析期间取消 |
|
||
| 5 中 | ZIP/结构异常边界缺失,加密成员/未知压缩/巨大整数会漏出原生异常 | **属实**: 实测 `float(10**400)` 抛 `OverflowError`,不在既有异常元组内; 漏出后 `_call` 不写终态行、探针不归还 | §5 明确异常转换边界,§10 加专项测试 |
|
||
| 6 中 | 逐块文字/空结果/未知类型/编码契约不完整 | **属实** | 新增 §5.1 结构契约表,§10 逐行一条用例 |
|
||
| 7 中 | bbox 放宽与\"继承使裁剪函数可通吃\"自相矛盾 | **属实** | 改为 `x1≤x2` 且 `y1≤y2`(允许零宽,去掉整数化退化),坐标系/页码约定写明 |
|
||
| 8 中 | 429 记账写错; 窗口条件写反; 遗漏 `call_deadline_s` | **全部属实**(`memory/breaker.py:84-90,235-244`): \"失败率<0.6\"是健康证据而非\"证据不足\" | §6/§7 订正,增 429 与期限两行 |
|
||
| 9 低 | `_summarize` 风险描述不准 | **属实**: 实际是漏记 `md_chars` | 措辞订正 |
|
||
| 引用核对 | `api/main.py:435-441` 行号偏移(分派在 `:164-170`); `config.py:503-507` 应为 `:508`; Redis 键缺 `pgw:` 前缀 | **属实** | 已订正 |
|
||
|
||
未采纳: 无(九条均成立,部分处置方式有调整)。**订正后未再送 Codex 复审**——修订量大,建议人类审阅时一并关注 §3 Q2、§5、§5.1、§7。
|