docs: address M3 OCR design review findings

This commit is contained in:
2026-07-21 21:50:56 -04:00
parent b8f7007d06
commit 40be3a22ef
@@ -23,7 +23,7 @@
| `/ocr/text` | `{"success":true,"task_type":"text","content":"...","message":"..."}` | content 为多行纯文本 | | `/ocr/text` | `{"success":true,"task_type":"text","content":"...","message":"..."}` | content 为多行纯文本 |
| `/parse` | `{"success":true,"message":"...","output_dir":"...","files":[...],"download_url":"/static/xxx.zip"}` | **download_url 是相对路径**,必须相对 base_url 解析(CHS 靠 httpx base_url 隐式做到;库需显式处理,兼容绝对 URL) | | `/parse` | `{"success":true,"message":"...","output_dir":"...","files":[...],"download_url":"/static/xxx.zip"}` | **download_url 是相对路径**,必须相对 base_url 解析(CHS 靠 httpx base_url 隐式做到;库需显式处理,兼容绝对 URL) |
| `_middle.json` | `pdf_info: [page]`;page 含 `page_idx`/`page_size:[w,h]`/`para_blocks`/`tables`/`images`/`interline_equations`/`preproc_blocks` 等 | 见下 | | `_middle.json` | `pdf_info: [page]`;page 含 `page_idx`/`page_size:[w,h]`/`para_blocks`/`tables`/`images`/`interline_equations`/`preproc_blocks` 等 | 见下 |
| **元素结构关系**(4 样本核实) | `para_blocks` 是统一带类型元素列表(type ∈ table/image/text/...,各含 bbox);`tables` 列表与其中 type=table 的块 **bbox 完全一致**(如 chs_0001 两处均为 [41,48,218,282]) | `elements``para_blocks` 统一提取即无损覆盖 CHS 首表路径;`tables` 不必单独建模 | | **元素结构关系**(**35 样本批量交叉核验,0 不一致**;含 17 无表 + 18 有表) | `para_blocks` 是统一带类型元素列表(type ∈ table/image/text/...,各含 bbox);`tables` 列表与其中 type=table 的块 **bbox 逐一相等**(如 chs_0001 两处均为 [41,48,218,282]) | `elements``para_blocks` 统一提取即无损覆盖 CHS 首表路径;`tables` 不必单独建模;integration/soak 各加一条两列表一致性断言持续护栏(§11) |
## 2. 备选方案对比 ## 2. 备选方案对比
@@ -78,7 +78,7 @@ class OcrTransport(Protocol): # 协议细节;不含治理
### 3.4 配置(config.py 新增 OcrSettings) ### 3.4 配置(config.py 新增 OcrSettings)
`OcrSettings` 仿 `EmbeddingSettings`: 内含 `gateway: GatewaySettings`(复用 `{SCOPE}__{PROVIDER}__{N}__{FIELD}` 多源键、per-scope 韧性键),**无 OCR 专用键**(cache/structured/pricing 键对 OCR 无意义,装配时忽略)。CHS 键名 `OCR__MONKEY__1__BASE_URL/API_KEY/MODEL/...` 原样可用(api_key 填 "none" 惯例——MonkeyOCR 无鉴权,SourceConfig 非空校验用占位;CHS .env 同款);TPM 闸不启用(tpm=0),看门狗键不配(非流式)。 `OcrSettings` 仿 `EmbeddingSettings`: 内含 `gateway: GatewaySettings`(复用 `{SCOPE}__{PROVIDER}__{N}__{FIELD}` 多源键、per-scope 韧性键),**无 OCR 专用键**(cache/structured/pricing 键对 OCR 无意义,装配时忽略)。CHS 键名 `OCR__MONKEY__1__BASE_URL/API_KEY/MODEL/...` 原样可用(api_key 填 "none" 惯例——MonkeyOCR 无鉴权,SourceConfig 非空校验用占位;CHS .env 同款);VT 的 `MONKEY_OCR_URLS` 逗号列表按迁移文档 §5 拆为 `OCR__MONKEY__{1,2}__BASE_URL`(api_key 同用 "none"),.env.example 补 OCR scope 样例段。TPM 闸不启用(tpm=0),看门狗键不配(非流式)。**装配防御**: `OcrClient.from_settings` 遇 provider ≠ "monkey" 的源显式报错(D9 GLM 预留档,配了 `OCR__GLM__*` 必须失败而非静默用 MonkeyOcrTransport 打 GLM 端点——严禁默认值掩盖错误)。
## 4. MonkeyOcrTransport(transports/monkey_ocr.py) ## 4. MonkeyOcrTransport(transports/monkey_ocr.py)
@@ -88,15 +88,15 @@ class OcrTransport(Protocol): # 协议细节;不含治理
| multipart 上传 | `files={"file": ("image.jpg", image, "image/jpeg")}` 固定名(CHS invokers.py:496 原样;服务按内容处理,4 样本实测通过) | CHS | | multipart 上传 | `files={"file": ("image.jpg", image, "image/jpeg")}` 固定名(CHS invokers.py:496 原样;服务按内容处理,4 样本实测通过) | CHS |
| `/ocr/text` 解析 | 2xx + JSON + `content` 必须为 str,缺失/非 str → `ResultInvalidError`;空串合法 | VT(`.get("content","")` 的静默兜底**有意替换**为显式校验——P5 严禁默认值掩盖错误) | | `/ocr/text` 解析 | 2xx + JSON + `content` 必须为 str,缺失/非 str → `ResultInvalidError`;空串合法 | VT(`.get("content","")` 的静默兜底**有意替换**为显式校验——P5 严禁默认值掩盖错误) |
| `/parse` 两段协议 | POST `/parse` → 校验 `success is True` + `download_url` 非空 str → GET(相对路径 urljoin base_url,绝对 URL 原样)→ ZIP | CHS invokers.py:489-530 逐段保真;相对路径显式化(取证 §1.2) | | `/parse` 两段协议 | POST `/parse` → 校验 `success is True` + `download_url` 非空 str → GET(相对路径 urljoin base_url,绝对 URL 原样)→ ZIP | CHS invokers.py:489-530 逐段保真;相对路径显式化(取证 §1.2) |
| `_middle.json` 校验 | ZIP 内首个 `*_middle.json`;`pdf_info` 必须 list;每页: dict、`page_size` 为 2 正有限数、`para_blocks` 缺省按空列表、每块 type 为 str、bbox 为 4 有限数且 x2>x1、y2>y1 且整数化后不退化 | CHS `_finite_number`/`_parse_table_result`(invokers.py:427-479)数值防御**全量下沉**,提取面从"首表"泛化为"全元素"(取证 §1.2 证明无损) | | `_middle.json` 校验 | ZIP 内首个 `*_middle.json`;`pdf_info` 必须 list;每页: dict、`page_size` 为 2 正有限数、`para_blocks` 缺省按空列表、每块 type 为 str、bbox 为 4 有限数且 x2>x1、y2>y1 且**整数化后不退化**(库返回 float 原生 bbox,此校验专为 CHS shim 的 `int()` 裁剪路径兜底——亚像素宽的退化框裁剪即空图,实现中须注释此由来防止被当死代码删除) | CHS `_finite_number`/`_parse_table_result`(invokers.py:427-479)数值防御**全量下沉**,提取面从"首表"泛化为"全元素"(取证 §1.2 证明无损) |
| 错误翻译 | 连接/超时 → `TransientError`(network_error/timeout);5xx/429 → Transient;401/403 → `SourceDeadError`;其余 4xx `success!=true``RequestRejectedError`;响应 JSON 坏/download_url 缺 → Transient;ZIP/`_middle.json` 坏 → **`ResultInvalidError`**(坏图 ≠ 坏服务) | CHS `_translate_http_errors` + OcrResultInvalid 语义;与库四分类逐条对应 | | 错误翻译 | 连接/超时 → `TransientError`(network_error/timeout);5xx/429 → Transient;401/403 → `SourceDeadError`;其余 4xx `RequestRejectedError`;`success!=true``RequestRejectedError` **并附 status_code=200**(200 响应确证服务活着,熔断按"响应即健康"记成功——见 §10.2 有意修复);响应 JSON 坏/download_url 缺 → Transient;ZIP/`_middle.json` 坏 → **`ResultInvalidError`**(坏图 ≠ 坏服务) | CHS `_translate_http_errors` 主体保真;CHS 429 细分(insufficient_quota→SourceDead、Retry-After 头解析)**有意放弃**——MonkeyOCR 无鉴权无计费,429 语义不存在,防御性归 Transient 即可(§10.2) |
| `check_health` | GET `{base_url}/health`,5s 固定超时,2xx → True;任何异常 → False(不翻译不上抛) | VT ocr.py:50-62(raise 语义**有意替换**为 bool——探测不是调用,由消费方决定成败) | | `check_health` | GET `{base_url}/health`,5s 固定超时(探测常量,同 jitter 系数先例不进配置),2xx → True;`except Exception` → False 不上抛;**`CancelledError` 穿透**(铁律) | VT ocr.py:50-62(raise 语义**有意替换**为 bool——探测不是调用,由消费方决定成败;VT 的 `resp.ok` 含 3xx,收紧为 2xx,见 §10.1) |
## 5. OcrClient 治理循环 ## 5. OcrClient 治理循环
`EmbeddingClient._embed_batch` 逐段同构(第三份有限重复,理由 §2.A;行为口径完全一致): `EmbeddingClient._embed_batch` 逐段同构(第三份有限重复,理由 §2.A;行为口径完全一致):
选源(cooldown memo → try_acquire → gate try_enter)→ 调 transport → 成功: gate 记成功 + mark_progress + settle(0);`ResultInvalidError`/`RequestRejectedError`(带 status_code): gate 记成功 `count_attempt=False` 后**直接上抛**(不重试不换源,消耗调用方预算——CHS governance.py:237-239 语义);`SourceDeadError`/`TransientError`: gate 记失败 + 喂 OutcomeAwareSelector + 计 fails,达 max_attempts 抛 `AllSourcesExhausted(retry_exhausted)`,否则退避换源;无可运行源: 全 gate 拒 → `CircuitOpenError`,quota_full=fail_fast → `AllSourcesExhausted(quota_exhausted)`,双条件 stall(本地超窗 AND progress_age 超窗)→ `AllSourcesExhausted(stalled)`;取消: 探针归还 + `CancelledError` 穿透。 选源(cooldown memo → try_acquire → gate try_enter)→ 调 transport → 成功: gate 记成功 + mark_progress + settle(0);`ResultInvalidError`/`RequestRejectedError`(带 status_code,含 `success!=true` 的 200): gate 记成功 `count_attempt=False` 后**直接上抛**(不重试不换源,消耗调用方预算——CHS governance.py:237-239 语义);不带 status_code 的 RequestRejected(本地请求拒绝,服务未响应): 仅归还探针,不记成功(embedding `_gate_on_terminal` 同口径);`SourceDeadError`/`TransientError`: gate 记失败 + 喂 OutcomeAwareSelector + 计 fails,达 max_attempts 抛 `AllSourcesExhausted(retry_exhausted)`,否则退避换源;无可运行源: 全 gate 拒 → `CircuitOpenError`,quota_full=fail_fast → `AllSourcesExhausted(quota_exhausted)`,双条件 stall(本地超窗 AND progress_age 超窗)→ `AllSourcesExhausted(stalled)`;取消: 探针归还 + `CancelledError` 穿透。
与 embedding 循环的**有意差异**仅三处: ① settle 恒为 0(无 token,失败也不按 est 保守结算——OCR 无计费无 TPM 闸);② 无批处理外循环;③ 喂 OutcomeAwareSelector(embedding 未接健康选源,OCR 接——多实例 LAN 服务单机可挂,M2.5 健康选源正是为此设计;喂数口径同 RetryMW: 真实成败喂、ResultInvalid/健康拒绝不喂)。 与 embedding 循环的**有意差异**仅三处: ① settle 恒为 0(无 token,失败也不按 est 保守结算——OCR 无计费无 TPM 闸);② 无批处理外循环;③ 喂 OutcomeAwareSelector(embedding 未接健康选源,OCR 接——多实例 LAN 服务单机可挂,M2.5 健康选源正是为此设计;喂数口径同 RetryMW: 真实成败喂、ResultInvalid/健康拒绝不喂)。
@@ -123,14 +123,14 @@ class OcrTransport(Protocol): # 协议细节;不含治理
| 维度 | 回答 | | 维度 | 回答 |
|---|---| |---|---|
| 并发与取消 | OcrClient 无共享可变状态(cooldown memo 每实例私有,与 embedding 同);`CancelledError` 穿透: 退避 sleep/限流等待/两段 HTTP(POST 与 GET 之间取消同样穿透)全部可取消,permit 在 finally settle+release,探针在取消时归还 | | 并发与取消 | OcrClient 无共享可变状态(cooldown memo 每实例私有,与 embedding 同);`CancelledError` 穿透: 退避 sleep/限流等待/两段 HTTP(POST 与 GET 之间取消同样穿透)/**check_health 并发探测**全部可取消,permit 在 finally settle+release,探针在取消时归还 |
| 降级方向 | 限流/熔断后端故障 → `GovernanceBackendError` 报错不放行(铁律);遥测写失败 → warning 降级;记账侧写回失败 → `_record_quietly` 同口径降级 | | 降级方向 | 限流/熔断后端故障 → `GovernanceBackendError` 报错不放行(铁律);遥测写失败 → warning 降级;记账侧写回失败 → `_record_quietly` 同口径降级 |
| 幂等与重复 | OCR 调用天然只读幂等,重放安全;/parse 服务端临时产物由服务自清理(实测 output_dir 在服务容器内),库不管理 | | 幂等与重复 | OCR 调用天然只读幂等,重放安全;/parse 服务端临时产物由服务自清理(实测 output_dir 在服务容器内),库不管理 |
| 持久化与原子性 | 库内无持久化;ZIP 解包在内存(BytesIO,CHS 同款),不落盘 | | 持久化与原子性 | 库内无持久化;ZIP 解包在内存(BytesIO,CHS 同款),不落盘 |
## 10. 旧版行为审计(逐条裁决) ## 10. 旧版行为审计(逐条裁决)
### 10.1 VT adapters/ocr.py(全 13 项行为,migrations/video-tree-trm5.md §8-13 对齐) ### 10.1 VT adapters/ocr.py(migrations/video-tree-trm5.md §8 表第 13 行的合并清单,逐项展开)
| 行为 | 裁决 | | 行为 | 裁决 |
|---|---| |---|---|
@@ -140,6 +140,7 @@ class OcrTransport(Protocol): # 协议细节;不含治理
| 单帧失败跳过返回空 / 行级过滤(len≤1)/ 帧内去重 / "帧N:" 拼接 | **业务侧保留**(迁移文档 §3 适配器,库不做——零业务假设) | | 单帧失败跳过返回空 / 行级过滤(len≤1)/ 帧内去重 / "帧N:" 拼接 | **业务侧保留**(迁移文档 §3 适配器,库不做——零业务假设) |
| `.get("content","")` 静默兜底 | **有意替换**: 显式校验,缺失 → ResultInvalid(§4) | | `.get("content","")` 静默兜底 | **有意替换**: 显式校验,缺失 → ResultInvalid(§4) |
| check_health 预检、任一不可达抛 RuntimeError | **保留能力、替换形态**: 逐源 dict[str,bool](§3.3;R10) | | check_health 预检、任一不可达抛 RuntimeError | **保留能力、替换形态**: 逐源 dict[str,bool](§3.3;R10) |
| check_health 判 `resp.ok`(<400,含 3xx) | **有意收紧**: 2xx 才算健康(3xx 重定向对 LAN 直连服务是异常信号) |
| `_TIMEOUT_S=300` 硬编码 | **替换**: source.timeout_s 配置驱动 | | `_TIMEOUT_S=300` 硬编码 | **替换**: source.timeout_s 配置驱动 |
### 10.2 CHS invokers.py:408-552(全部行为) ### 10.2 CHS invokers.py:408-552(全部行为)
@@ -147,7 +148,9 @@ class OcrTransport(Protocol): # 协议细节;不含治理
| 行为 | 裁决 | | 行为 | 裁决 |
|---|---| |---|---|
| 两段协议(POST /parse → GET download_url → ZIP) | **保留**(逐段保真;相对路径解析显式化) | | 两段协议(POST /parse → GET download_url → ZIP) | **保留**(逐段保真;相对路径解析显式化) |
| `success is not True` → RequestRejected;download_url 缺 → Transient;JSON 坏 → Transient | **保留**(§4 错误翻译逐条对应) | | `success is not True` → RequestRejected(**不带 status_code**,governance.py:228-236 因此走"本地拒绝"分支——仅释放探针、不记 gate 成功) | **有意修复**: 库附 status_code=200,熔断按"响应即健康"记成功(count_attempt=False)。CHS 原行为等于把服务的明确业务拒绝当成"网关没响应",与其自身"HTTP 响应可关闭 gate"的注释意图相悖 |
| download_url 缺 → Transient;JSON 坏 → Transient | **保留**(§4 错误翻译逐条对应) |
| 429 细分翻译(`_translate_429`: insufficient_quota → SourceDead、Retry-After 头解析取较大退避) | **有意放弃**: MonkeyOCR 无鉴权无计费,429/配额语义不存在;防御性统一归 Transient(§4) |
| `_finite_number`/page_size 正数/bbox 有限性/顺序/整数化退化校验 | **保留全量下沉**(§4) | | `_finite_number`/page_size 正数/bbox 有限性/顺序/整数化退化校验 | **保留全量下沉**(§4) |
| 首表提取(`tables[0]`)、int bbox、OcrTableMatch/OcrParseOutcome 类型 | **替换**: 全元素列表 + float 原生 bbox(ARCH §7.10 "elements 全量";取证证明 para_blocks 无损覆盖);CHS 侧 shim: 取首个 type=="table" 元素 + int() 四元组,约 5 行 | | 首表提取(`tables[0]`)、int bbox、OcrTableMatch/OcrParseOutcome 类型 | **替换**: 全元素列表 + float 原生 bbox(ARCH §7.10 "elements 全量";取证证明 para_blocks 无损覆盖);CHS 侧 shim: 取首个 type=="table" 元素 + int() 四元组,约 5 行 |
| 无表格页跳过继续找、全页无表 → None | **语义承接**: elements 中无 table 元素 = 合法"无表",不抛异常 | | 无表格页跳过继续找、全页无表 → None | **语义承接**: elements 中无 table 元素 = 合法"无表",不抛异常 |
@@ -161,11 +164,15 @@ class OcrTransport(Protocol): # 协议细节;不含治理
| 层 | 内容 | | 层 | 内容 |
|---|---| |---|---|
| unit | transport 协议解析: 用本日取证的真实响应二次构造 fixtures(`/ocr/text` JSON、`/parse` JSON、真实 `_middle.json`坏 ZIP/坏 JSON/非有限 bbox 变体);错误翻译逐分类;相对/绝对 download_url | | unit | transport 协议解析: 用本日取证的真实响应**脱敏二次构造** fixtures——保留结构骨架/数值/元素类型,**一切 OCR 识别文本字段(lines/content 等)替换为合成占位**(真实语料是护理记录/医疗影像,零业务假设铁律禁止业务 fixtures,P5 医疗数据不入库);变体: 坏 ZIP/坏 JSON/非有限 bbox/退化 bbox;错误翻译逐分类;相对/绝对 download_url;check_health 取消穿透 |
| unit | OcrClient 循环: 桩 transport 注入,覆盖换源/退避/ResultInvalid 直抛(gate 记成功 count_attempt=False)/探针取消归还/stall 双条件/G1 字段非空 | | unit | OcrClient 循环: 桩 transport 注入,覆盖换源/退避/ResultInvalid 直抛(gate 记成功 count_attempt=False)/探针取消归还/stall 双条件/G1 字段非空 |
| integration | 真实服务双端点(10.77.0.20)各打通一次 + check_health;真实 Redis 后端组合(与既有 integration 同款参数化) | | integration | 真实服务双端点(10.77.0.20)各打通一次 + check_health;真实 Redis 后端组合(与既有 integration 同款参数化);**tables/para_blocks 一致性断言**(§1.2 结论的持续护栏,soak 记分板同断言) |
| soak | P7 场景(§8),验收出口: ROADMAP §4(双端点集成测试通过 + 两项目替换路径可行) | | soak | P7 场景(§8),验收出口: ROADMAP §4(双端点集成测试通过 + 两项目替换路径可行) |
## 12. 落地时需同步的文档 ## 12. 落地时需同步的文档
ARCH §7.10(check_health 签名细化 + download_url 相对路径事实)、migrations/chsanalyzer.md(G1 改已闭、§4 表 OcrLayoutPort 消费 shim 更新)、migrations/video-tree-trm5.md(R9/R10 改已闭)、ROADMAP(M3 状态)、.env.example(OCR scope 样例段) - ARCH D9(:208)与 §7.10(:459)的"OCR 走同一中间件栈"措辞改为"复用同一套治理算法件与错误分类,循环形态同 EmbeddingClient 先例(M2 §7.1 有限重复裁决)"——消除与本设计方案 A 的单一事实源矛盾;ROADMAP §4 ③ 同步
- ARCH §7.10: check_health 签名细化为 `dict[str, bool]` + download_url 相对路径协议事实;§5.1 OCR 类型字段清单按 §3.1 五件溯源更新。
- migrations/chsanalyzer.md: G1 改已闭(仅剩项目侧 10 行翻译 shim)、§4 表 OcrLayoutPort 消费 shim 更新(首个 type=="table" 元素 + int 四元组)、§10.2 的 success!=true 有意修复与 429 有意放弃入偏离表。
- migrations/video-tree-trm5.md: R9/R10 改已闭;§8-13 行裁决与 §10.1 对齐。
- ROADMAP(M3 状态)、.env.example(OCR scope 样例段,含 VT 双实例与 api_key="none" 惯例)。