Files
PolyGateway/research-wiki/designs/2026-07-21-m3-ocr-design.md
T

20 KiB

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]) elementspara_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: strbbox: tuple[float,float,float,float]page_index: int type 开放字符串(实测 table/image/text,不枚举锁死——零业务假设);bbox 为 OCR 原生页面坐标(x1,y1,x2,y2)
OcrTextResult text: str + 溯源: source_nameusage: Usage(0 token)、latency_ms: intcall_id: strraw: 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)

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_envtimeout=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 原样;服务按内容处理,4 样本实测通过) 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!=trueRequestRejectedError 并附 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 保留能力、替换形态: 逐源 dictstr,bool
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" 惯例)。