359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""
|
|
Translation Service Abstraction
|
|
Provides a unified interface for different translation providers
|
|
"""
|
|
from abc import ABC, abstractmethod
|
|
from typing import Optional, List
|
|
import requests
|
|
from deep_translator import GoogleTranslator, DeeplTranslator, LibreTranslator
|
|
from config import config
|
|
|
|
|
|
class TranslationProvider(ABC):
|
|
"""Abstract base class for translation providers"""
|
|
|
|
@abstractmethod
|
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
|
"""Translate text from source to target language"""
|
|
pass
|
|
|
|
|
|
class GoogleTranslationProvider(TranslationProvider):
|
|
"""Google Translate implementation"""
|
|
|
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
|
if not text or not text.strip():
|
|
return text
|
|
|
|
try:
|
|
translator = GoogleTranslator(source=source_language, target=target_language)
|
|
return translator.translate(text)
|
|
except Exception as e:
|
|
print(f"Translation error: {e}")
|
|
return text
|
|
|
|
|
|
class DeepLTranslationProvider(TranslationProvider):
|
|
"""DeepL Translate implementation"""
|
|
|
|
def __init__(self, api_key: str):
|
|
self.api_key = api_key
|
|
|
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
|
if not text or not text.strip():
|
|
return text
|
|
|
|
try:
|
|
translator = DeeplTranslator(api_key=self.api_key, source=source_language, target=target_language)
|
|
return translator.translate(text)
|
|
except Exception as e:
|
|
print(f"Translation error: {e}")
|
|
return text
|
|
|
|
|
|
class LibreTranslationProvider(TranslationProvider):
|
|
"""LibreTranslate implementation"""
|
|
|
|
def __init__(self, custom_url: str = "https://libretranslate.com"):
|
|
self.custom_url = custom_url
|
|
|
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
|
if not text or not text.strip():
|
|
return text
|
|
|
|
try:
|
|
# LibreTranslator supports custom URL for self-hosted or public instances
|
|
translator = LibreTranslator(source=source_language, target=target_language, custom_url=self.custom_url)
|
|
return translator.translate(text)
|
|
except Exception as e:
|
|
print(f"LibreTranslate error: {e}")
|
|
# Fail silently and return original text
|
|
return text
|
|
|
|
|
|
class OllamaTranslationProvider(TranslationProvider):
|
|
"""Ollama LLM translation implementation"""
|
|
|
|
def __init__(self, base_url: str = "http://localhost:11434", model: str = "llama3", vision_model: str = "llava", system_prompt: str = ""):
|
|
self.base_url = base_url.rstrip('/')
|
|
self.model = model.strip() # Remove any leading/trailing whitespace
|
|
self.vision_model = vision_model.strip()
|
|
self.custom_system_prompt = system_prompt # Custom context, glossary, instructions
|
|
|
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
|
if not text or not text.strip():
|
|
return text
|
|
|
|
# Skip very short text or numbers only
|
|
if len(text.strip()) < 2 or text.strip().isdigit():
|
|
return text
|
|
|
|
try:
|
|
# Build system prompt with custom context if provided
|
|
base_prompt = f"You are a translator. Translate the user's text to {target_language}. Return ONLY the translation, nothing else."
|
|
|
|
if self.custom_system_prompt:
|
|
system_content = f"""{base_prompt}
|
|
|
|
ADDITIONAL CONTEXT AND INSTRUCTIONS:
|
|
{self.custom_system_prompt}"""
|
|
else:
|
|
system_content = base_prompt
|
|
|
|
# Use /api/chat endpoint (more compatible with all models)
|
|
response = requests.post(
|
|
f"{self.base_url}/api/chat",
|
|
json={
|
|
"model": self.model,
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": system_content
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": text
|
|
}
|
|
],
|
|
"stream": False,
|
|
"options": {
|
|
"temperature": 0.3,
|
|
"num_predict": 500
|
|
}
|
|
},
|
|
timeout=120 # 2 minutes timeout
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
translated = result.get("message", {}).get("content", "").strip()
|
|
return translated if translated else text
|
|
except requests.exceptions.ConnectionError:
|
|
print(f"Ollama error: Cannot connect to {self.base_url}. Is Ollama running?")
|
|
return text
|
|
except requests.exceptions.Timeout:
|
|
print(f"Ollama error: Request timeout after 120s")
|
|
return text
|
|
except Exception as e:
|
|
print(f"Ollama translation error: {e}")
|
|
return text
|
|
|
|
def translate_image(self, image_path: str, target_language: str) -> str:
|
|
"""Translate text within an image using Ollama vision model"""
|
|
import base64
|
|
|
|
try:
|
|
# Read and encode image
|
|
with open(image_path, 'rb') as img_file:
|
|
image_data = base64.b64encode(img_file.read()).decode('utf-8')
|
|
|
|
# Use /api/chat for vision models too
|
|
response = requests.post(
|
|
f"{self.base_url}/api/chat",
|
|
json={
|
|
"model": self.vision_model,
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": f"Extract all text from this image and translate it to {target_language}. Return ONLY the translated text, preserving the structure and formatting.",
|
|
"images": [image_data]
|
|
}
|
|
],
|
|
"stream": False
|
|
},
|
|
timeout=60
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
return result.get("message", {}).get("content", "").strip()
|
|
except Exception as e:
|
|
print(f"Ollama vision translation error: {e}")
|
|
return ""
|
|
|
|
@staticmethod
|
|
def list_models(base_url: str = "http://localhost:11434") -> List[str]:
|
|
"""List available Ollama models"""
|
|
try:
|
|
response = requests.get(f"{base_url.rstrip('/')}/api/tags", timeout=5)
|
|
response.raise_for_status()
|
|
models = response.json().get("models", [])
|
|
return [model["name"] for model in models]
|
|
except Exception as e:
|
|
print(f"Error listing Ollama models: {e}")
|
|
return []
|
|
|
|
|
|
class WebLLMTranslationProvider(TranslationProvider):
|
|
"""WebLLM browser-based translation (client-side processing)"""
|
|
|
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
|
# WebLLM translation happens client-side in the browser
|
|
# This is just a placeholder - actual translation is done by JavaScript
|
|
# For server-side, we'll just pass through for now
|
|
return text
|
|
|
|
|
|
class OpenAITranslationProvider(TranslationProvider):
|
|
"""OpenAI GPT translation implementation with vision support"""
|
|
|
|
def __init__(self, api_key: str, model: str = "gpt-4o-mini", system_prompt: str = ""):
|
|
self.api_key = api_key
|
|
self.model = model
|
|
self.custom_system_prompt = system_prompt
|
|
|
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
|
if not text or not text.strip():
|
|
return text
|
|
|
|
# Skip very short text or numbers only
|
|
if len(text.strip()) < 2 or text.strip().isdigit():
|
|
return text
|
|
|
|
try:
|
|
import openai
|
|
client = openai.OpenAI(api_key=self.api_key)
|
|
|
|
# Build system prompt with custom context if provided
|
|
base_prompt = f"You are a translator. Translate the user's text to {target_language}. Return ONLY the translation, nothing else."
|
|
|
|
if self.custom_system_prompt:
|
|
system_content = f"""{base_prompt}
|
|
|
|
ADDITIONAL CONTEXT AND INSTRUCTIONS:
|
|
{self.custom_system_prompt}"""
|
|
else:
|
|
system_content = base_prompt
|
|
|
|
response = client.chat.completions.create(
|
|
model=self.model,
|
|
messages=[
|
|
{"role": "system", "content": system_content},
|
|
{"role": "user", "content": text}
|
|
],
|
|
temperature=0.3,
|
|
max_tokens=500
|
|
)
|
|
|
|
translated = response.choices[0].message.content.strip()
|
|
return translated if translated else text
|
|
except Exception as e:
|
|
print(f"OpenAI translation error: {e}")
|
|
return text
|
|
|
|
def translate_image(self, image_path: str, target_language: str) -> str:
|
|
"""Translate text within an image using OpenAI vision model"""
|
|
import base64
|
|
|
|
try:
|
|
import openai
|
|
client = openai.OpenAI(api_key=self.api_key)
|
|
|
|
# Read and encode image
|
|
with open(image_path, 'rb') as img_file:
|
|
image_data = base64.b64encode(img_file.read()).decode('utf-8')
|
|
|
|
# Determine image type from extension
|
|
ext = image_path.lower().split('.')[-1]
|
|
media_type = f"image/{ext}" if ext in ['png', 'jpg', 'jpeg', 'gif', 'webp'] else "image/png"
|
|
|
|
response = client.chat.completions.create(
|
|
model=self.model, # gpt-4o and gpt-4o-mini support vision
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": f"Extract all text from this image and translate it to {target_language}. Return ONLY the translated text, preserving the structure and formatting."
|
|
},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": f"data:{media_type};base64,{image_data}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
],
|
|
max_tokens=1000
|
|
)
|
|
|
|
return response.choices[0].message.content.strip()
|
|
except Exception as e:
|
|
print(f"OpenAI vision translation error: {e}")
|
|
return ""
|
|
|
|
|
|
class TranslationService:
|
|
"""Main translation service that delegates to the configured provider"""
|
|
|
|
def __init__(self, provider: Optional[TranslationProvider] = None):
|
|
if provider:
|
|
self.provider = provider
|
|
else:
|
|
# Auto-select provider based on configuration
|
|
self.provider = self._get_default_provider()
|
|
self.translate_images = False # Flag to enable image translation
|
|
|
|
def _get_default_provider(self) -> TranslationProvider:
|
|
"""Get the default translation provider from configuration"""
|
|
# Always use Google Translate by default to avoid API key issues
|
|
# Provider will be overridden per request in the API endpoint
|
|
return GoogleTranslationProvider()
|
|
|
|
def translate_text(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
|
"""
|
|
Translate a single text string
|
|
|
|
Args:
|
|
text: Text to translate
|
|
target_language: Target language code (e.g., 'es', 'fr', 'de')
|
|
source_language: Source language code (default: 'auto' for auto-detection)
|
|
|
|
Returns:
|
|
Translated text
|
|
"""
|
|
if not text or not text.strip():
|
|
return text
|
|
|
|
return self.provider.translate(text, target_language, source_language)
|
|
|
|
def translate_image(self, image_path: str, target_language: str) -> str:
|
|
"""
|
|
Translate text in an image using vision model (Ollama or OpenAI)
|
|
|
|
Args:
|
|
image_path: Path to image file
|
|
target_language: Target language code
|
|
|
|
Returns:
|
|
Translated text from image
|
|
"""
|
|
if not self.translate_images:
|
|
return ""
|
|
|
|
# Ollama and OpenAI support image translation
|
|
if isinstance(self.provider, OllamaTranslationProvider):
|
|
return self.provider.translate_image(image_path, target_language)
|
|
elif isinstance(self.provider, OpenAITranslationProvider):
|
|
return self.provider.translate_image(image_path, target_language)
|
|
|
|
return ""
|
|
|
|
def translate_batch(self, texts: list[str], target_language: str, source_language: str = 'auto') -> list[str]:
|
|
"""
|
|
Translate multiple text strings
|
|
|
|
Args:
|
|
texts: List of texts to translate
|
|
target_language: Target language code
|
|
source_language: Source language code (default: 'auto')
|
|
|
|
Returns:
|
|
List of translated texts
|
|
"""
|
|
return [self.translate_text(text, target_language, source_language) for text in texts]
|
|
|
|
|
|
# Global translation service instance
|
|
translation_service = TranslationService()
|