初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,12 @@
|
||||
"""天目 OCR 集成:HTTP 客户端 + 响应模型。"""
|
||||
|
||||
from app.integrations.ocr.client import OCRBlock, OCRClient, OCRResult
|
||||
from app.integrations.ocr.schemas import OCRBlockSchema, OCRResultSchema
|
||||
|
||||
__all__ = [
|
||||
"OCRBlock",
|
||||
"OCRBlockSchema",
|
||||
"OCRClient",
|
||||
"OCRResult",
|
||||
"OCRResultSchema",
|
||||
]
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
天目 OCR HTTP 客户端。
|
||||
|
||||
封装对天目微服务的 HTTP 调用,提供同步/异步 OCR 接口。
|
||||
后端其他模块通过此客户端调用 OCR 能力,不直接依赖 HTTP 细节。
|
||||
|
||||
特性:
|
||||
- 自动重试(网络故障 / 5xx 错误,指数退避)
|
||||
- 连接池复用(httpx.AsyncClient 自动管理)
|
||||
- 端点级别超时配置(/healthz 短超时,/v1/ocr 适中,/v1/ocr/vl 长超时)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.logging import logger
|
||||
|
||||
TIANMU_DEFAULT_BASE = "http://localhost:8101"
|
||||
|
||||
# 各端点默认超时(秒)
|
||||
_TIMEOUTS = {
|
||||
"health": 5.0,
|
||||
"ocr": 60.0,
|
||||
"ocr_vl": 600.0,
|
||||
}
|
||||
|
||||
# 重试配置
|
||||
_MAX_RETRIES = 2
|
||||
_RETRYABLE_STATUSES = {502, 503, 504}
|
||||
_RETRY_BASE_DELAY = 0.5 # 秒
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCRBlock:
|
||||
"""单行 OCR 识别结果。"""
|
||||
|
||||
text: str
|
||||
confidence: float
|
||||
bbox: list[list[float]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OCRResult:
|
||||
"""统一 OCR 响应。"""
|
||||
|
||||
engine: str
|
||||
text: str
|
||||
blocks: list[OCRBlock] = field(default_factory=list)
|
||||
duration_ms: float = 0.0
|
||||
error: str | None = None
|
||||
saved_md_path: str | None = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.error is None
|
||||
|
||||
|
||||
class OCRClient:
|
||||
"""天目 OCR 服务 HTTP 客户端。
|
||||
|
||||
用法::
|
||||
|
||||
client = OCRClient()
|
||||
result = await client.ocr(image_bytes)
|
||||
await client.close()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "",
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
timeout: float = 600.0,
|
||||
max_retries: int = _MAX_RETRIES,
|
||||
) -> None:
|
||||
self.base_url = (base_url or TIANMU_DEFAULT_BASE).rstrip("/")
|
||||
self._max_retries = max_retries
|
||||
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
||||
self._own_http = http_client is None
|
||||
|
||||
async def close(self) -> None:
|
||||
"""释放 HTTP 客户端。"""
|
||||
if self._own_http:
|
||||
await self._http.aclose()
|
||||
|
||||
# ── 通用 OCR(PP-OCRv6 优先)─────────────────────────
|
||||
|
||||
async def ocr(
|
||||
self,
|
||||
image: bytes | str,
|
||||
*,
|
||||
engine: str = "auto",
|
||||
lang: list[str] | None = None,
|
||||
save_md: bool = False,
|
||||
) -> OCRResult:
|
||||
"""通用文字识别。
|
||||
|
||||
Args:
|
||||
image: 图片二进制数据或 HTTP URL。
|
||||
engine: 引擎选择 auto / ppocrv6 / paddleocr-vl。
|
||||
lang: 语言列表(默认中英文,仅 ppocrv6 生效)。
|
||||
save_md: 是否保存结果为 Markdown 文件。
|
||||
|
||||
Returns:
|
||||
OCRResult: 识别结果。
|
||||
"""
|
||||
body: dict[str, Any] = {"engine": engine}
|
||||
if lang:
|
||||
body["lang"] = lang
|
||||
if save_md:
|
||||
body["save_md"] = True
|
||||
|
||||
body["image"] = _encode_image(image)
|
||||
|
||||
data = await self._request_with_retry(
|
||||
"POST",
|
||||
f"{self.base_url}/v1/ocr",
|
||||
json=body,
|
||||
timeout=_TIMEOUTS["ocr"],
|
||||
)
|
||||
if data is None:
|
||||
return OCRResult(engine=engine, error="天目服务不可达(重试耗尽)")
|
||||
|
||||
blocks = [
|
||||
OCRBlock(
|
||||
text=b["text"],
|
||||
confidence=b["confidence"],
|
||||
bbox=b.get("bbox", {}).get("points", []),
|
||||
)
|
||||
for b in data.get("blocks", [])
|
||||
]
|
||||
|
||||
return OCRResult(
|
||||
engine=data.get("engine", ""),
|
||||
text=data.get("text", ""),
|
||||
blocks=blocks,
|
||||
duration_ms=data.get("duration_ms", 0),
|
||||
error=data.get("error"),
|
||||
saved_md_path=data.get("saved_md_path"),
|
||||
)
|
||||
|
||||
# ── 复杂文档解析(PaddleOCR-VL)──────────────────────
|
||||
|
||||
async def ocr_vl(
|
||||
self,
|
||||
image: bytes | str,
|
||||
*,
|
||||
output_format: str = "markdown",
|
||||
save_md: bool = False,
|
||||
) -> OCRResult:
|
||||
"""复杂文档解析(PaddleOCR-VL)。
|
||||
|
||||
Args:
|
||||
image: 图片二进制数据或 HTTP URL。
|
||||
output_format: 输出格式 markdown / json / text。
|
||||
save_md: 是否保存结果为 Markdown 文件。
|
||||
|
||||
Returns:
|
||||
OCRResult: 结构化解析结果。
|
||||
"""
|
||||
body: dict[str, Any] = {"output_format": output_format}
|
||||
if save_md:
|
||||
body["save_md"] = True
|
||||
|
||||
body["image"] = _encode_image(image)
|
||||
|
||||
data = await self._request_with_retry(
|
||||
"POST",
|
||||
f"{self.base_url}/v1/ocr/vl",
|
||||
json=body,
|
||||
timeout=_TIMEOUTS["ocr_vl"],
|
||||
)
|
||||
if data is None:
|
||||
return OCRResult(engine="paddleocr-vl", error="天目服务不可达(重试耗尽)")
|
||||
|
||||
return OCRResult(
|
||||
engine=data.get("engine", "paddleocr-vl"),
|
||||
text=data.get("text", ""),
|
||||
duration_ms=data.get("duration_ms", 0),
|
||||
error=data.get("error"),
|
||||
saved_md_path=data.get("saved_md_path"),
|
||||
)
|
||||
|
||||
# ── 健康检查 ──────────────────────────────────────────
|
||||
|
||||
async def health(self) -> dict:
|
||||
"""检查天目服务健康状态。"""
|
||||
resp = await self._http.get(
|
||||
f"{self.base_url}/healthz",
|
||||
timeout=_TIMEOUTS["health"],
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── 内部:重试逻辑 ────────────────────────────────────
|
||||
|
||||
async def _request_with_retry(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
json: dict[str, Any] | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> dict[str, Any] | None:
|
||||
"""带指数退避重试的 HTTP 请求。
|
||||
|
||||
Args:
|
||||
method: HTTP 方法。
|
||||
url: 请求 URL。
|
||||
json: JSON 请求体。
|
||||
timeout: 请求超时(秒)。
|
||||
|
||||
Returns:
|
||||
响应 JSON 数据,重试耗尽返回 None。
|
||||
"""
|
||||
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
resp = await self._http.request(method, url, json=json, timeout=timeout)
|
||||
|
||||
# 可重试的服务器错误
|
||||
if resp.status_code in _RETRYABLE_STATUSES and attempt < self._max_retries:
|
||||
delay = _RETRY_BASE_DELAY * (2 ** attempt)
|
||||
logger.warning(
|
||||
"天目返回 {},{}s 后重试 ({}/{})",
|
||||
resp.status_code, delay, attempt + 1, self._max_retries,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
except (httpx.TimeoutException, httpx.ConnectError, httpx.RemoteProtocolError) as e:
|
||||
if attempt < self._max_retries:
|
||||
delay = _RETRY_BASE_DELAY * (2 ** attempt)
|
||||
logger.warning(
|
||||
"天目请求失败 ({},{}s 后重试): {}",
|
||||
type(e).__name__, delay, e,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
logger.error("天目请求重试耗尽 ({}): {}", type(e).__name__, e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _encode_image(image: bytes | str | Path) -> str:
|
||||
"""将图片编码为 base64 字符串。
|
||||
|
||||
Args:
|
||||
image: 图片二进制数据、文件路径或 HTTP URL。
|
||||
|
||||
Returns:
|
||||
base64 编码字符串(二进制输入)或原 URL。
|
||||
"""
|
||||
if isinstance(image, Path):
|
||||
image = image.read_bytes()
|
||||
if isinstance(image, str):
|
||||
return image
|
||||
|
||||
import base64
|
||||
return base64.b64encode(image).decode("ascii")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""天目 OCR 响应模型(Pydantic 版,供 API 路由使用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OCRBlockSchema(BaseModel):
|
||||
"""单行识别结果(Pydantic 版)。"""
|
||||
|
||||
text: str = Field(description="识别文字")
|
||||
confidence: float = Field(description="置信度 0~1", ge=0, le=1)
|
||||
bbox: list[list[float]] = Field(default_factory=list, description="文本框坐标")
|
||||
|
||||
|
||||
class OCRResultSchema(BaseModel):
|
||||
"""统一 OCR 响应模型。"""
|
||||
|
||||
engine: str = Field(description="实际使用的引擎")
|
||||
text: str = Field(default="", description="识别文本")
|
||||
blocks: list[OCRBlockSchema] = Field(default_factory=list, description="逐块结果")
|
||||
duration_ms: float = Field(default=0, description="推理耗时(毫秒)")
|
||||
error: str | None = Field(default=None, description="错误信息")
|
||||
saved_md_path: str | None = Field(default=None, description="保存的 Markdown 路径")
|
||||
Reference in new issue
Block a user