"""MonkeyOCR HTTP 客户端 — 帧文字转录的异构硬证据源。 服务由用户在 LAN 部署(双端点轮询);请求必须绕过代理(trust_env=False)。 实现 OCRProvider Protocol(app/ports.py)。 """ from __future__ import annotations import asyncio import itertools import threading from pathlib import Path # noqa: TC003 — 运行时需要(方法签名 + open()) import requests from loguru import logger _TIMEOUT_S = 15 class MonkeyOCRClient: """MonkeyOCR 服务客户端:多端点轮询、单帧失败降级为跳过。 关键实现细节:实例可被多线程共享——端点轮询加锁、Session 线程局部 (A/B 评测会以 4 线程并发调用同一实例)。 参数: urls: 服务端点列表(如 ["http://10.77.0.20:7866", ...]),非空。 异常: ValueError: urls 为空时抛出。 """ def __init__(self, urls: list[str]) -> None: if not urls: raise ValueError("MonkeyOCR 端点列表不能为空") self._urls = [u.rstrip("/") for u in urls] self._rr = itertools.cycle(self._urls) self._rr_lock = threading.Lock() self._local = threading.local() def _get_session(self) -> requests.Session: """返回当前线程专属的 Session(惰性创建并复用,trust_env=False 绕代理)。""" session = getattr(self._local, "session", None) if session is None: session = requests.Session() session.trust_env = False # LAN 直连,绕过代理 self._local.session = session return session def _check_health_sync(self) -> None: """同步预检所有端点,任一不可达即抛错(供 asyncio.to_thread 调用)。 异常: RuntimeError: 端点不可达或 /health 非 2xx。 """ for url in self._urls: try: resp = self._get_session().get(f"{url}/health", timeout=5) except requests.RequestException as e: raise RuntimeError(f"MonkeyOCR 端点不可达: {url}: {e}") from e if not resp.ok: raise RuntimeError(f"MonkeyOCR 健康检查失败: {url}: {resp.status_code}") async def check_health(self) -> None: """异步预检所有端点,任一不可达即抛错(A/B qtr_ocr 臂启动门)。 异常: RuntimeError: 端点不可达或 /health 非 2xx。 """ await asyncio.to_thread(self._check_health_sync) def _transcribe_frames_sync(self, frame_paths: list[Path]) -> str: """同步逐帧转录并拼接(供 asyncio.to_thread 调用)。 参数: frame_paths: 帧文件路径列表。 返回: "帧1: <行1> | <行2>\\n帧2: ..." 格式文本;无任何有效结果时空串。 """ parts: list[str] = [] for i, path in enumerate(frame_paths, 1): lines = self._transcribe_one(path) if lines: parts.append(f"帧{i}: " + " | ".join(lines)) return "\n".join(parts) async def transcribe_frames(self, frame_paths: list[Path]) -> str: """异步逐帧转录并拼接为注入文本;单帧失败跳过,全失败返回空串。 参数: frame_paths: 帧文件路径列表。 返回: "帧1: <行1> | <行2>\\n帧2: ..." 格式文本;无任何有效结果时空串。 """ return await asyncio.to_thread(self._transcribe_frames_sync, frame_paths) def _transcribe_one(self, path: Path) -> list[str]: """单帧转录:空结果/单字符行过滤 + 帧内行级去重。 参数: path: 帧文件路径。 返回: 过滤去重后的文本行列表;请求失败或无有效行时空列表。 """ with self._rr_lock: url = next(self._rr) try: with open(path, "rb") as f: resp = self._get_session().post( f"{url}/ocr/text", files={"file": f}, timeout=_TIMEOUT_S ) resp.raise_for_status() content = resp.json().get("content", "") except (requests.RequestException, ValueError) as e: logger.warning("MonkeyOCR 单帧转录失败,跳过 {}: {}", path.name, e) return [] seen: set[str] = set() lines: list[str] = [] for ln in content.splitlines(): ln = ln.strip() if len(ln) <= 1 or ln in seen: continue seen.add(ln) lines.append(ln) return lines