Files
PolyGateway/research-wiki/designs/2026-07-21-m3-ocr-design.md
T
iomgaa 6691e52195 docs: document parse_document and bump version to 1.3.11
Changelog, README (install pin, capability table, usage, deployment recipe, operations notes, known limits), env example, architecture, roadmap, the M3 design erratum and the CHSAnalyzer3 consumption notes.
2026-10-22 00:23:47 -04:00

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]) 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)

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)。勘误(2026-10-22,#34): "服务按内容处理"只在图片样本上验证过,不适用于 PDF——服务按文件名扩展名分派(.pdf/.jpg/.jpeg/.png),PDF 标 image.jpg 时 /ocr/text 返回 success:true 的幻觉文本;上传标签现按内容判定,见 2026-10-21-issue34-ocr-document-design.md §1 E3/E4/E10 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 保留能力、替换形态: 逐源 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" 惯例)。