diff --git a/research-wiki/plans/2026-07-21-m3-ocr-plan.md b/research-wiki/plans/2026-07-21-m3-ocr-plan.md index 328471f..7e517c6 100644 --- a/research-wiki/plans/2026-07-21-m3-ocr-plan.md +++ b/research-wiki/plans/2026-07-21-m3-ocr-plan.md @@ -4,6 +4,8 @@ - **方案概述**: 方案 A——仿 `EmbeddingClient`(`src/polygateway/embedding.py`)的独立精简治理循环,复用 QuotaGate/BreakerGate/backoff_delay/_failure_reason/SourceCooldownMemo/TelemetryEmitter/选源器;新增 `OcrTransport` 端口与 `transports/monkey_ocr.py`。不触碰 `middleware/retry.py`。 - **涉及技术**: Python 3.11 + httpx + zipfile/BytesIO(标准库,无新依赖);真实 MonkeyOCR 服务 10.77.0.20:7866/7867;分支 `feature/m3-ocr`;所有命令 `conda run -n PolyGateway `。 - **执行方式**: 中等规模、任务间强顺序依赖 → 直接按计划实现,不用 subagent-driven-development。 +- **structured-logging 已完成**(SOP Phase1-2,先于本计划): 无新表,复用 `llm_calls`;metric `ocr-call-success` 已注册(`research-wiki/metrics/ocr-call-success.md`,含口径/埋点位置/基线,commit e51f077),边 metric→schema、schema→design 已连。埋点清单落 T5.4。 +- **测试结果门(全任务统一)**: 每个行为任务的新测试用例必须**先于实现运行一次并出示失败证据**(ImportError/AttributeError/断言失败),实现后转 PASS——T1-T8 的验证行均按此执行,不再逐条重复。 ## 文件结构(锁定分解) @@ -112,7 +114,7 @@ class OcrTransport(Protocol): - `SourceDeadError`/`TransientError`: `record_failure(entry, reason, dead)` + `selector.record_outcome(name, False)` + reasons[name]=reason;fails+=1,`fails >= retry.max_attempts` → `AllSourcesExhausted(retry_exhausted, retry_after_s=backoff_base_s, per_source_reasons=reasons)`;否则非 dead 退避 `backoff_delay(...)` 后换源。 - permit 在 finally `settle(0)` + `release()`(恒 0,OCR 无 token)。 - 无可运行源: 全 gate 拒 → `CircuitOpenError(retry_after_s=await breaker.retry_after_s(names))`;fail_fast → `AllSourcesExhausted(quota_exhausted)`;双条件 stall(本地超 stall_window_s AND `progress_age_s()` 超窗)→ `AllSourcesExhausted(stalled)`;否则 poll_interval 抖动等待。 -4. 遥测(设计 §7): 每次尝试(成败/取消)经 `TelemetryEmitter.emit_attempt` 单 helper;request 占位 `[{"role":"user","content":f""}]`;成功 response 用 LLMResponse 包装: content = text 截断 200 字 / `f""`,tokens=0、ttft/inter=None、cache_hit=False;写失败降级不冒泡(Emitter 内建)。 +4. 遥测(设计 §7): 每次尝试(成败/取消)经 `TelemetryEmitter.emit_attempt` 单 helper;request 占位 messages **包成 `ChatRequest`**(embedding.py `_emit`@345 先例)——`[{"role":"user","content":f""}]`;成功 response 用 LLMResponse 包装: content = text 截断 200 字 / `f""`,tokens=0、ttft/inter=None、cache_hit=False;写失败降级不冒泡(Emitter 内建)。 5. `check_health() -> dict[str, bool]`: `asyncio.gather` 并发逐源 `transport.check_health(source=s)`,返回 `{name: bool}`;不吞 CancelledError(gather 天然穿透);不健康源 logger.warning。 6. 工厂: `from_settings(OcrSettings, *, limiter=None, breaker=None, telemetry=None)` 复用 client.py 的 `_build_limiter/_build_breaker/_build_selector/_build_telemetry`;**装配防御**: 任一 source.provider != "monkey" → `ValueError`(D9 GLM 预留档,严禁静默用 MonkeyOcrTransport 打别家端点);`from_env(scope="OCR")` 委托。`aclose()`/async context manager 与 EmbeddingClient 对称。 7. G1 钉住: 上述三处 scope 级异常全部携带非空 `per_source_reasons` 与 `retry_after_s`(circuit_open/stalled 读熔断后端)。 @@ -123,7 +125,7 @@ class OcrTransport(Protocol): - SourceDead: 立即换源(immediate,不退避)、force_open; - ResultInvalid: 直接上抛、gate 记成功且 count_attempt=False(RecordingGate 桩断言)、不喂 selector; - RequestRejected 带 status_code: 同上直抛记成功;不带 status_code: 仅探针归还; - - retry_exhausted: 抛 AllSourcesExhausted,`per_source_reasons` 非空、`retry_after_s > 0`(G1 契约);circuit_open 与 stalled 同断言; + - retry_exhausted: 抛 AllSourcesExhausted,`per_source_reasons` 非空、`retry_after_s > 0`(**此三条断言即设计 §6 ③ 的 G1 契约钉,落在本单测文件,不另进 tests/contracts/**——contracts 层是限流/熔断后端双实现契约,G1 是客户端循环行为);circuit_open 与 stalled 同断言; - stall 双条件: 本地超窗但 progress 新鲜 → 继续等(不抛);双超 → stalled; - 取消穿透: sleep 中取消、transport 调用中取消(探针归还断言)、permit finally 释放; - fail_fast: quota 满即抛 quota_exhausted; @@ -155,9 +157,9 @@ class OcrTransport(Protocol): ## T8 P7 OCR soak 场景 1. `.env` 增 OCR soak 池(**先读现有 .env 再追加,严禁编造/覆盖**;凭据只在 python 内经 dotenv 读): `SOAK_OCR__MONKEY__{1..4}__*`——源1=`http://10.77.0.20:7866`(真)、源2=`http://10.77.0.20:7867`(真)、源3=`http://10.255.255.1:7866`(黑洞)、源4=`http://10.77.0.20:7899`(坏端口,连接拒绝);api_key 全 "none";源级 RPM/并发参照 CHS 惯例(MAX_CONCURRENCY=4/RPM=120)+ TIMEOUT_S=120;scope 键 `SOAK_OCR__RETRY__MAX_ATTEMPTS=3`。 -2. `tools/soak/scenarios.py`: 新增 P7 生成器——语料 `data/soak/chs_images/` 全量循环,kind 新增 `"ocr_text"`/`"ocr_layout"`(权重 0.8/0.2),kwargs=`{"image": bytes}`;总量 1500 调用。 +2. `tools/soak/scenarios.py`: 新增 P7 生成器——语料 `data/soak/chs_images/` 全量循环(实测 469 张,设计 §8 "约 300" 以实际为准),kind 新增 `"ocr_text"`/`"ocr_layout"`(权重 0.8/0.2),kwargs=`{"image": bytes}`;总量 1500 调用。 3. `tools/soak/run_soak.py`: 装配 `OcrClient`(P7 时替代 GatewayClient;复用 `_paced_dispatch` 有界分发);按 kind 分派两方法;失败打印含 `per_source_reasons` 链(既有格式)。 -4. `tools/soak/scoreboard.py`: P7 口径裁剪——无 429/缓存/token 列;不变量: ① 成功率 ≥98%(双真源在池);② 坏源(源3/4)尝试占比 ≤15%(健康选源压制);③ 熔断对坏源开路且真源零误熔(gate 事件核对);④ RPM 逐源不超限(准入时刻+服务器钟偏移聚桶,复用既有函数);⑤ 全程 RSS 有界(< 500MB,ps 当前值);⑥ tables/para_blocks 一致性抽查(layout 成功调用抽 20 条);⑦ 失败链全部可解释(reason ∈ 已知集合);⑧ 零 CancelledError 泄漏/零未分类异常。 +4. `tools/soak/scoreboard.py`: P7 口径裁剪——无 429/缓存/token 列;不变量: ① 成功率 ≥98%(双真源在池);② 坏源(源3/4)尝试占比 ≤15%(健康选源压制);③ 熔断对坏源开路且真源零误熔(gate 事件核对);④ RPM 逐源不超限(准入时刻+服务器钟偏移聚桶,复用既有函数);⑤ 全程 RSS 有界(< 500MB 绝对值,ps 当前值——**新增检查函数**,不改既有 `inv_rss_stable` 增长口径);⑥ tables/para_blocks 一致性抽查(layout 成功调用抽 20 条);⑦ 失败链全部可解释(reason ∈ 已知集合);⑧ 零 CancelledError 泄漏/零未分类异常(**有意裁决**: 设计 §8 的"取消穿透抽查"由 T5 单测主动注入覆盖,soak 只做被动泄漏核查——harness 无取消注入能力,不为此扩建)。 5. 跑 P7(tmux + 禁日志缓存),结果落 `research-wiki/findings/2026-07-21-p7-ocr-soak.md`(阈值不达标 → systematic-debugging 迭代,测试环境不放水)。 - 验证: 记分板 8 不变量全 PASS。 - [ ] 提交 `feat: add P7 OCR soak scenario and scoreboard`(代码)+ `docs: record P7 OCR soak acceptance`(findings)