# M3 OCR 端口族设计

- **日期**: 2026-07-21;**状态**: 待人类审批(公共 API,强制人类门)
- **范围拍板**(用户 2026-07-21): ROADMAP §4 既定范围 + G1 销账;GLM OCR 按 D9 继续预留;OCR 纳入 soak 压测体系;验收打真实 MonkeyOCR 服务
- **上游依据**: ARCHITECTURE §7.10(端口族冻结面)、D9(端口族而非单接口)、ROADMAP §4(内部顺序与验收出口)、migrations/video-tree-trm5.md §9-R9/R10、migrations/chsanalyzer.md G1/R6

## 1. 需求与实况取证

### 1.1 两个蓝本(需求来源)

| 蓝本 | 端点 | 用途 | 治理现状 |
|---|---|---|---|
| `reference/Video-Tree-TRM5/adapters/ocr.py`(128 行) | `POST /ocr/text` → JSON `{content}` | 帧文字硬证据注入 VLM 提示词 | **裸调**(同步 requests、双端点轮询、单帧失败跳过)→ 迁移后免费升级全治理 |
| `reference/CHSAnalyzer/app/providers/invokers.py:408-552` | `POST /parse` → JSON(download_url)→ GET ZIP → `_middle.json` | 定位护理记录表格 bbox | 已全治理(governance.run 泛型核心) |

### 1.2 真实服务协议取证(2026-07-21,10.77.0.20:7866/7867 实测双活)

用 `data/soak/chs_images/` 真实语料打真实服务,得到以下**协议事实**(样本已存,将二次构造为测试 fixtures):

| 事实 | 内容 | 设计影响 |
|---|---|---|
| `/health` | 200 + `{"status":"healthy","model_loaded":true}` | check_health 只判 2xx,不假设 body 结构 |
| `/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) |
| `_middle.json` | `pdf_info: [page]`;page 含 `page_idx`/`page_size:[w,h]`/`para_blocks`/`tables`/`images`/`interline_equations`/`preproc_blocks` 等 | 见下 |
| **元素结构关系**(**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. 备选方案对比

| 方案 | 做法 | 权衡 |
|---|---|---|
| **A. 独立治理循环 OcrClient(推荐)** | 仿 `EmbeddingClient`(M2 §7 方案 G2 先例): 独立精简治理循环,**复用算法件**——`QuotaGate`/`BreakerGate`/`backoff_delay`/`SourceCooldownMemo`/`_failure_reason`/错误四分类/`TelemetryEmitter`/选源器(含 OutcomeAwareSelector 喂数);新增 `OcrTransport` 端口 + `transports/monkey_ocr.py` | 有限重复第三份选源等待循环(chat/embed/ocr),但 OCR 不需要 chat 循环的 429 免预算、AIMD、流式看门狗、缓存、结构化重问——强行合一才是复制(M2 §7.1 已裁决过一次同类问题);风险最小,不触碰 M2.5 刚验收的 472 行 retry.py |
| B. 泛化治理核心 | 把 RetryMW 重构为模态无关的 `governance.run(call)`(CHS 形态),chat/embed/ocr 三入口共用 | 算法真正一份,但需重构 M2.5 六机制刚验收的核心(AIMD/降权/429 免预算全是 chat 特有,泛化后要开关化),回归风险大、收益低;**否决** |
| C. OCR 伪装 ChatRequest 走既有洋葱 | 图像塞 messages,复用全部中间件 | 类型语义崩坏(cache/structured 中间件需空转旁路)、违背"显式优于隐式";**否决** |

方案 A 与 CHS 自身现状同构(CHS 的 VLM/OCR 共用泛型核心,是因为它们的循环需求完全一致;本库 chat 循环已长出 OCR 不需要的六机制,同构前提不再成立)。

## 3. 公共 API(冻结面,人类门审这里)

### 3.1 类型(types.py 新增,全部 frozen dataclass)

| 类型 | 字段 | 说明 |
|---|---|---|
| `OcrLayoutElement` | `type: str`、`bbox: tuple[float,float,float,float]`、`page_index: int` | type 开放字符串(实测 table/image/text,不枚举锁死——零业务假设);bbox 为 OCR 原生页面坐标(x1,y1,x2,y2) |
| `OcrTextResult` | `text: str` + 溯源: `source_name`、`usage: Usage`(0 token)、`latency_ms: int`、`call_id: str`、`raw: dict` | text 空串 = 合法"无文字";行过滤/去重/拼帧留业务侧(VT 迁移 §3 适配器) |
| `OcrLayoutResult` | `elements: list[OcrLayoutElement]`、`page_sizes: list[tuple[float,float]]`(按 page_index 索引)+ 同上溯源五件 | elements 空 = 合法"无元素";CHS 首表 = 首个 type=="table" 元素(迁移 shim 一行) |
| `OcrTextTransportResult` / `OcrLayoutTransportResult` | 上述业务字段 + `raw`(无溯源件) | transport → OcrClient 内部产物,治理字段由 OcrClient 补齐(与 EmbeddingTransportResult 先例同构) |

### 3.2 端口(ports.py 新增,均 @runtime_checkable)

```python
class OcrTextPort(Protocol):      # 对应 POST /ocr/text
    async def recognize_text(self, image: bytes) -> OcrTextResult: ...

class OcrLayoutPort(Protocol):    # 对应 POST /parse → GET ZIP
    async def parse_layout(self, image: bytes) -> OcrLayoutResult: ...

class OcrTransport(Protocol):     # 协议细节;不含治理
    async def recognize_text(self, *, image: bytes, source: SourceConfig, call_id: str) -> OcrTextTransportResult: ...
    async def parse_layout(self, *, image: bytes, source: SourceConfig, call_id: str) -> OcrLayoutTransportResult: ...
    async def check_health(self, *, source: SourceConfig) -> bool: ...
```

输入统一 `bytes`(D9: 路径读取/批量拼帧留业务侧;多后端图片形态差异封装在 transport 内)。

### 3.3 OcrClient(embedding.py 同级新文件 ocr.py)

一个 `OcrClient` 同时实现两端口(两端点打同一服务实例池,共享同一 scope 的限流/熔断/选源状态),另暴露健康预检:

| 成员 | 签名 | 说明 |
|---|---|---|
| 构造 | 与 EmbeddingClient 对称: scope/sources/selector/limiter/breaker/transport(OcrTransport)/retry/backpressure/quota_full/telemetry + now/sleep/rng 注入 | 无 pricing(OCR 无计费)、无 batch/normalize/expected_dim |
| `recognize_text(image, *, session_id=None, parent_call_id=None)` | → `OcrTextResult` | 治理循环见 §5 |
| `parse_layout(image, *, session_id=None, parent_call_id=None)` | → `OcrLayoutResult` | 同上 |
| `check_health()` | → `dict[str, bool]`(源名 → 是否健康) | **R10 销账**。逐源 GET /health(5s 超时,2xx=True),并发执行,异常=False 不上抛(预检是探测不是调用);业务 `all(r.values())` 即得 VT 启动门布尔。**注**: ARCH §7.10 原文 "check_health() -> bool 逐源健康预检" 存在签名与语义的含糊(bool 无法承载"逐源"),本设计取 dict 形态并将修订 §7.10 措辞——被否决的备选: 返回 bool(全通过才 True)信息量不足,业务无法定位坏源打日志 |
| `from_env(scope="OCR") / from_settings(OcrSettings)` | 与 EmbeddingClient 对称;显式传入 limiter/breaker 实例即共享 | |
| `aclose() / async with` | 幂等释放 transport 连接池与遥测 | |

### 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 同款);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)

| 关切 | 决策 | 蓝本出处 |
|---|---|---|
| HTTP 客户端 | per-source `httpx.AsyncClient`(惰性建,keyed by source.name),`trust_env=source.trust_env`、`timeout=source.timeout_s` | **R9 销账**: SourceConfig.trust_env + `TRUST_ENV` 配置键 M1 已落地,openai_compat.py:203 已尊重,本 transport 同款;VT LAN 直连绕代理场景配 `OCR__MONKEY__N__TRUST_ENV=false` |
| multipart 上传 | `files={"file": ("image.jpg", image, "image/jpeg")}` 固定名(CHS invokers.py:496 原样;服务按内容处理,35 样本实测通过,核验输出存档 `data/soak/m3_xcheck_results.txt`) | CHS |
| `/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) |
| `_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 → `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 固定超时(探测常量,同 jitter 系数先例不进配置),2xx → True;`except Exception` → False 不上抛;**`CancelledError` 穿透**(铁律) | VT ocr.py:50-62(raise 语义**有意替换**为 bool——探测不是调用,由消费方决定成败;VT 的 `resp.ok` 含 3xx,收紧为 2xx,见 §10.1) |

## 5. OcrClient 治理循环

与 `EmbeddingClient._embed_batch` 逐段同构(第三份有限重复,理由 §2.A;行为口径完全一致):

选源(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/健康拒绝不喂)。

**不做清单(YAGNI,有意放弃)**: AIMD pacer(无 429 语义;并发保护走限流 max_concurrency 闸)、429 免预算(同前)、流式看门狗(非流式,总超时由 httpx timeout 承担)、响应缓存(两蓝本均无此需求;多模态缓存 key 需图像摘要,待真实需求出现再议)、结构化策略(不适用)。

## 6. G1 销账(核实结论,非新工作)

迁移文档 G1 所要求的结构化字段**在 M1/M2 已全部落地**: `GatewayUnavailableError` 携带 `scope/reason/retry_after_s/per_source_reasons`(errors.py:74-105),`CircuitOpenError`/`AllSourcesExhausted` 为其子类;retry.py 与 embedding.py 的全部抛出点均已填充(retry_after_s 读熔断后端最早恢复时刻)。M3 行动: ① OcrClient 同口径抛出(§5 已含);② 迁移文档 G1 行改"已闭"并注明 CHS 侧仅剩 10 行翻译 shim(库异常 → ProviderUnavailableError)或 tracking.py 直接 except 库异常;③ 契约测试补一条"OCR 循环抛出的 scope 级异常携带非空 per_source_reasons 与 retry_after_s"钉住。

## 7. 遥测

复用 `TelemetryEmitter` 单 helper 与 llm_calls 18 字段表(embedding 先例,无新表): `messages` = `[{"role":"user","content":"<ocr:text|layout image_bytes=N>"}]` 占位(图像 bytes 绝不入库);`response` = text 截断 200 字 / `<elements n=X pages=Y>` 摘要;tokens=0、cost=None、ttft/inter=None、cache_hit=False;失败记 error。每次调用(含失败)必录,写失败降级不冒泡。structured-logging skill 在设计批准后走一遍完成 wiki 注册(复用 schema,无新实体,预计只补 metric)。

## 8. Soak 扩展(P7 OCR 场景,验收随实现交付)

| 项 | 设计 |
|---|---|
| 源池 | 源1=7866(真)、源2=7867(真)、源3=黑洞 10.255.255.1(超时)、源4=同机坏端口(连接拒绝)——保持"故障池"精神,坏源不删 |
| 语料 | `data/soak/chs_images/` 真实图像(约 300 张,已在本机) |
| 负载 | text/parse 混合(比例计划定),harness 复用 tools/soak 既有骨架(记分板按 OCR 口径裁剪: 无 429/缓存列) |
| 不变量(草案,阈值在 plan 冻结) | 成功率(双真源在池应 ≥98%)、坏源吸流压制(尝试占比阈值)、熔断开路/恢复行为、全程 RSS 有界、取消穿透抽查 |

## 9. 非功能维度(逐条)

| 维度 | 回答 |
|---|---|
| 并发与取消 | OcrClient 无共享可变状态(cooldown memo 每实例私有,与 embedding 同);`CancelledError` 穿透: 退避 sleep/限流等待/两段 HTTP(POST 与 GET 之间取消同样穿透)/**check_health 并发探测**全部可取消,permit 在 finally settle+release,探针在取消时归还 |
| 降级方向 | 限流/熔断后端故障 → `GovernanceBackendError` 报错不放行(铁律);遥测写失败 → warning 降级;记账侧写回失败 → `_record_quietly` 同口径降级 |
| 幂等与重复 | OCR 调用天然只读幂等,重放安全;/parse 服务端临时产物由服务自清理(实测 output_dir 在服务容器内),库不管理 |
| 持久化与原子性 | 库内无持久化;ZIP 解包在内存(BytesIO,CHS 同款),不落盘 |

## 10. 旧版行为审计(逐条裁决)

### 10.1 VT adapters/ocr.py(migrations/video-tree-trm5.md §8 表第 13 行的合并清单,逐项展开)

| 行为 | 裁决 |
|---|---|
| 同步 requests + 线程局部 Session | **替换**: httpx.AsyncClient(纯 asyncio 铁律) |
| trust_env=False 绕代理 | **保留**(per-source 配置键,§4;R9) |
| 双端点加锁轮询 | **替换**: 多源选源策略(升级) |
| 单帧失败跳过返回空 / 行级过滤(len≤1)/ 帧内去重 / "帧N:" 拼接 | **业务侧保留**(迁移文档 §3 适配器,库不做——零业务假设) |
| `.get("content","")` 静默兜底 | **有意替换**: 显式校验,缺失 → ResultInvalid(§4) |
| check_health 预检、任一不可达抛 RuntimeError | **保留能力、替换形态**: 逐源 dict[str,bool](§3.3;R10) |
| check_health 判 `resp.ok`(<400,含 3xx) | **有意收紧**: 2xx 才算健康(3xx 重定向对 LAN 直连服务是异常信号) |
| `_TIMEOUT_S=300` 硬编码 | **替换**: source.timeout_s 配置驱动 |

### 10.2 CHS invokers.py:408-552(全部行为)

| 行为 | 裁决 |
|---|---|
| 两段协议(POST /parse → GET download_url → ZIP) | **保留**(逐段保真;相对路径解析显式化) |
| `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) |
| 首表提取(`tables[0]`)、int bbox、OcrTableMatch/OcrParseOutcome 类型 | **替换**: 全元素列表 + float 原生 bbox(ARCH §7.10 "elements 全量";取证证明 para_blocks 无损覆盖);CHS 侧 shim: 取首个 type=="table" 元素 + int() 四元组,约 5 行 |
| 无表格页跳过继续找、全页无表 → None | **语义承接**: elements 中无 table 元素 = 合法"无表",不抛异常 |
| OcrResultInvalid → 熔断记成功、不换源、耗任务预算 | **保留**(ResultInvalidError + count_attempt=False,§5) |
| Usage(total_tokens=0, elapsed_s) | **保留语义**: usage 0 token + latency_ms 溯源字段(库 Usage 无 elapsed,latency_ms 独立承载) |
| governance.run 泛型核心接入 | **替换**: OcrClient 独立循环(§2 裁决) |

## 11. 错误处理与测试策略

失败分类归宿见 §4 翻译表与 §5 循环;测试四层:

| 层 | 内容 |
|---|---|
| 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 字段非空 |
| integration | 真实服务双端点(10.77.0.20)各打通一次 + check_health;真实 Redis 后端组合(与既有 integration 同款参数化);**tables/para_blocks 一致性断言**(§1.2 结论的持续护栏,soak 记分板同断言) |
| soak | P7 场景(§8),验收出口: ROADMAP §4(双端点集成测试通过 + 两项目替换路径可行) |

## 12. 落地时需同步的文档

- 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" 惯例)。
