- Update config.py to read both .env and .env.local (with .env.local priority) - Move DIFY API settings from hardcoded values to environment configuration - Remove unused PADDLEOCR_MODEL_DIR setting (models stored in ~/.paddleocr/) - Remove deprecated argostranslate translation settings - Add DIFY settings: base_url, api_key, timeout, max_retries, batch limits - Update dify_client.py to use settings from config.py - Update translation_service.py to use settings instead of constants - Fix frontend env files to use correct variable name VITE_API_BASE_URL - Update setup_dev_env.sh with correct PaddlePaddle version (3.2.0) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
339 lines
10 KiB
Python
339 lines
10 KiB
Python
"""
|
|
Tool_OCR - DIFY AI Client
|
|
HTTP client for DIFY translation API with batch support
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List, Optional
|
|
|
|
import httpx
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Language name mapping
|
|
LANGUAGE_NAMES = {
|
|
"en": "English",
|
|
"zh-TW": "Traditional Chinese",
|
|
"zh-CN": "Simplified Chinese",
|
|
"ja": "Japanese",
|
|
"ko": "Korean",
|
|
"de": "German",
|
|
"fr": "French",
|
|
"es": "Spanish",
|
|
"pt": "Portuguese",
|
|
"it": "Italian",
|
|
"ru": "Russian",
|
|
"vi": "Vietnamese",
|
|
"th": "Thai",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class TranslationResponse:
|
|
"""Response from DIFY translation API"""
|
|
translated_text: str
|
|
total_tokens: int
|
|
latency: float
|
|
conversation_id: str
|
|
|
|
|
|
@dataclass
|
|
class BatchTranslationResponse:
|
|
"""Response from DIFY batch translation API"""
|
|
translations: Dict[int, str] # marker_id -> translated_text
|
|
total_tokens: int
|
|
latency: float
|
|
conversation_id: str
|
|
missing_markers: List[int] = field(default_factory=list)
|
|
|
|
|
|
class DifyTranslationError(Exception):
|
|
"""Error during DIFY API translation"""
|
|
pass
|
|
|
|
|
|
class DifyClient:
|
|
"""
|
|
Client for DIFY AI translation API.
|
|
|
|
Features:
|
|
- Single and batch translation
|
|
- Blocking mode API calls
|
|
- Automatic retry with exponential backoff
|
|
- Token and latency tracking
|
|
|
|
Configuration is loaded from settings (config.py / .env.local):
|
|
- DIFY_BASE_URL: API base URL
|
|
- DIFY_API_KEY: API key (required)
|
|
- DIFY_TIMEOUT: Request timeout in seconds
|
|
- DIFY_MAX_RETRIES: Max retry attempts
|
|
- DIFY_MAX_BATCH_CHARS: Max characters per batch
|
|
- DIFY_MAX_BATCH_ITEMS: Max items per batch
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
timeout: Optional[float] = None,
|
|
max_retries: Optional[int] = None
|
|
):
|
|
# Use settings as defaults when not explicitly provided
|
|
self.base_url = base_url or settings.dify_base_url
|
|
self.api_key = api_key or settings.dify_api_key
|
|
self.timeout = timeout if timeout is not None else settings.dify_timeout
|
|
self.max_retries = max_retries if max_retries is not None else settings.dify_max_retries
|
|
self.max_batch_chars = settings.dify_max_batch_chars
|
|
self.max_batch_items = settings.dify_max_batch_items
|
|
self._total_tokens = 0
|
|
self._total_requests = 0
|
|
|
|
# Warn if API key is not configured
|
|
if not self.api_key:
|
|
logger.warning(
|
|
"DIFY_API_KEY not configured. Set DIFY_API_KEY in .env.local for translation to work."
|
|
)
|
|
|
|
def _get_language_name(self, lang_code: str) -> str:
|
|
"""Convert language code to full name for prompt"""
|
|
return LANGUAGE_NAMES.get(lang_code, lang_code)
|
|
|
|
def _build_prompt(self, text: str, target_lang: str) -> str:
|
|
"""Build translation prompt for single text"""
|
|
lang_name = self._get_language_name(target_lang)
|
|
return (
|
|
f"Translate the following text to {lang_name}.\n"
|
|
f"Return ONLY the translated text, no explanations.\n\n"
|
|
f"{text}"
|
|
)
|
|
|
|
def _build_batch_prompt(self, texts: List[str], target_lang: str) -> str:
|
|
"""
|
|
Build batch translation prompt with numbered markers.
|
|
|
|
Format:
|
|
Translate the following texts to {Language}.
|
|
Each text is marked with [N]. Return translations in the same format.
|
|
Return ONLY the translations with their markers, no explanations.
|
|
|
|
[1] First text
|
|
[2] Second text
|
|
...
|
|
"""
|
|
lang_name = self._get_language_name(target_lang)
|
|
|
|
# Build numbered text list
|
|
numbered_texts = []
|
|
for i, text in enumerate(texts, start=1):
|
|
# Clean text - remove newlines within each item to avoid parsing issues
|
|
clean_text = ' '.join(text.split())
|
|
numbered_texts.append(f"[{i}] {clean_text}")
|
|
|
|
texts_block = "\n".join(numbered_texts)
|
|
|
|
prompt = (
|
|
f"Translate the following texts to {lang_name}.\n"
|
|
f"Each text is marked with [N]. Return translations in the same format.\n"
|
|
f"Return ONLY the translations with their markers, no explanations.\n\n"
|
|
f"{texts_block}"
|
|
)
|
|
|
|
return prompt
|
|
|
|
def _parse_batch_response(self, response_text: str, expected_count: int) -> Dict[int, str]:
|
|
"""
|
|
Parse batch translation response with numbered markers.
|
|
|
|
Expected format:
|
|
[1] 翻譯文字一
|
|
[2] 翻譯文字二
|
|
...
|
|
|
|
Returns:
|
|
Dict mapping marker number to translated text
|
|
"""
|
|
translations = {}
|
|
|
|
# Pattern to match [N] followed by text until next [N] or end
|
|
# Use DOTALL to match across lines, but be careful with greedy matching
|
|
pattern = r'\[(\d+)\]\s*(.+?)(?=\[\d+\]|$)'
|
|
matches = re.findall(pattern, response_text, re.DOTALL)
|
|
|
|
for match in matches:
|
|
try:
|
|
marker_id = int(match[0])
|
|
text = match[1].strip()
|
|
if text:
|
|
translations[marker_id] = text
|
|
except (ValueError, IndexError):
|
|
continue
|
|
|
|
return translations
|
|
|
|
def _call_api(self, prompt: str, user_id: str) -> dict:
|
|
"""Make API call to DIFY with retry logic"""
|
|
payload = {
|
|
"inputs": {},
|
|
"query": prompt,
|
|
"response_mode": "blocking",
|
|
"conversation_id": "",
|
|
"user": user_id
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
last_error = None
|
|
|
|
for attempt in range(self.max_retries):
|
|
try:
|
|
with httpx.Client(timeout=self.timeout) as client:
|
|
response = client.post(
|
|
f"{self.base_url}/chat-messages",
|
|
json=payload,
|
|
headers=headers
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
raise DifyTranslationError(
|
|
f"API returned status {response.status_code}: {response.text}"
|
|
)
|
|
|
|
return response.json()
|
|
|
|
except httpx.TimeoutException as e:
|
|
last_error = e
|
|
logger.warning(f"DIFY API timeout (attempt {attempt + 1}/{self.max_retries})")
|
|
|
|
except httpx.RequestError as e:
|
|
last_error = e
|
|
logger.warning(f"DIFY API request error (attempt {attempt + 1}/{self.max_retries}): {e}")
|
|
|
|
except Exception as e:
|
|
last_error = e
|
|
logger.warning(f"DIFY API error (attempt {attempt + 1}/{self.max_retries}): {e}")
|
|
|
|
# Exponential backoff
|
|
if attempt < self.max_retries - 1:
|
|
wait_time = 2 ** attempt
|
|
logger.info(f"Retrying in {wait_time}s...")
|
|
time.sleep(wait_time)
|
|
|
|
raise DifyTranslationError(f"API call failed after {self.max_retries} attempts: {last_error}")
|
|
|
|
def translate(
|
|
self,
|
|
text: str,
|
|
target_lang: str,
|
|
user_id: str = "tool-ocr"
|
|
) -> TranslationResponse:
|
|
"""
|
|
Translate single text using DIFY API.
|
|
|
|
Args:
|
|
text: Text to translate
|
|
target_lang: Target language code (e.g., 'en', 'zh-TW')
|
|
user_id: User identifier for tracking
|
|
|
|
Returns:
|
|
TranslationResponse with translated text and metadata
|
|
"""
|
|
prompt = self._build_prompt(text, target_lang)
|
|
data = self._call_api(prompt, user_id)
|
|
|
|
# Extract response fields
|
|
translated_text = data.get("answer", "")
|
|
usage = data.get("metadata", {}).get("usage", {})
|
|
|
|
self._total_tokens += usage.get("total_tokens", 0)
|
|
self._total_requests += 1
|
|
|
|
return TranslationResponse(
|
|
translated_text=translated_text,
|
|
total_tokens=usage.get("total_tokens", 0),
|
|
latency=usage.get("latency", 0.0),
|
|
conversation_id=data.get("conversation_id", "")
|
|
)
|
|
|
|
def translate_batch(
|
|
self,
|
|
texts: List[str],
|
|
target_lang: str,
|
|
user_id: str = "tool-ocr"
|
|
) -> BatchTranslationResponse:
|
|
"""
|
|
Translate multiple texts in a single API call.
|
|
|
|
Args:
|
|
texts: List of texts to translate
|
|
target_lang: Target language code
|
|
user_id: User identifier for tracking
|
|
|
|
Returns:
|
|
BatchTranslationResponse with translations dict and metadata
|
|
"""
|
|
if not texts:
|
|
return BatchTranslationResponse(
|
|
translations={},
|
|
total_tokens=0,
|
|
latency=0.0,
|
|
conversation_id=""
|
|
)
|
|
|
|
prompt = self._build_batch_prompt(texts, target_lang)
|
|
|
|
logger.debug(f"Batch translation: {len(texts)} items, ~{len(prompt)} chars")
|
|
|
|
data = self._call_api(prompt, user_id)
|
|
|
|
# Extract and parse response
|
|
answer = data.get("answer", "")
|
|
usage = data.get("metadata", {}).get("usage", {})
|
|
|
|
translations = self._parse_batch_response(answer, len(texts))
|
|
|
|
# Check for missing markers
|
|
missing_markers = []
|
|
for i in range(1, len(texts) + 1):
|
|
if i not in translations:
|
|
missing_markers.append(i)
|
|
logger.warning(f"Missing translation for marker [{i}]")
|
|
|
|
self._total_tokens += usage.get("total_tokens", 0)
|
|
self._total_requests += 1
|
|
|
|
return BatchTranslationResponse(
|
|
translations=translations,
|
|
total_tokens=usage.get("total_tokens", 0),
|
|
latency=usage.get("latency", 0.0),
|
|
conversation_id=data.get("conversation_id", ""),
|
|
missing_markers=missing_markers
|
|
)
|
|
|
|
def get_stats(self) -> dict:
|
|
"""Get client statistics"""
|
|
return {
|
|
"total_tokens": self._total_tokens,
|
|
"total_requests": self._total_requests,
|
|
"base_url": self.base_url,
|
|
}
|
|
|
|
|
|
# Global singleton
|
|
_dify_client: Optional[DifyClient] = None
|
|
|
|
|
|
def get_dify_client() -> DifyClient:
|
|
"""Get the global DifyClient instance"""
|
|
global _dify_client
|
|
if _dify_client is None:
|
|
_dify_client = DifyClient()
|
|
return _dify_client
|