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