feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-03-07 11:42:58 +01:00
parent 3d37ce4582
commit 473b3e26c7
181 changed files with 30617 additions and 7170 deletions

View File

@@ -5,10 +5,13 @@ This service provides user authentication with automatic backend selection:
- If DATABASE_URL is configured: Uses PostgreSQL database
- Otherwise: Falls back to JSON file storage (development mode)
"""
import os
import secrets
import hashlib
from datetime import datetime, timedelta
import uuid
import time
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import json
from pathlib import Path
@@ -19,6 +22,7 @@ logger = logging.getLogger(__name__)
# Try to import optional dependencies
try:
import jwt
JWT_AVAILABLE = True
except ImportError:
JWT_AVAILABLE = False
@@ -26,6 +30,7 @@ except ImportError:
try:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
PASSLIB_AVAILABLE = True
except ImportError:
@@ -41,6 +46,7 @@ if USE_DATABASE:
from database.repositories import UserRepository
from database.connection import get_sync_session, init_db as _init_db
from database import models as db_models
DATABASE_AVAILABLE = True
logger.info("Database backend enabled for authentication")
except ImportError as e:
@@ -49,21 +55,89 @@ if USE_DATABASE:
logger.warning(f"Database modules not available: {e}. Using JSON storage.")
else:
DATABASE_AVAILABLE = False
logger.info("Using JSON file storage for authentication (DATABASE_URL not configured)")
logger.info(
"Using JSON file storage for authentication (DATABASE_URL not configured)"
)
from models.subscription import User, UserCreate, PlanType, SubscriptionStatus, PLANS
# Configuration
SECRET_KEY = os.getenv("JWT_SECRET", os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32)))
_jwt_secret = os.getenv("JWT_SECRET", os.getenv("JWT_SECRET_KEY"))
if not _jwt_secret:
_jwt_secret = secrets.token_urlsafe(32)
logger.critical(
"SECURITY: JWT_SECRET_KEY is not configured! Using an ephemeral random key. "
"ALL JWT TOKENS WILL BE INVALIDATED ON EVERY RESTART. "
"Set JWT_SECRET_KEY in your .env file immediately."
)
SECRET_KEY = _jwt_secret
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
REFRESH_TOKEN_EXPIRE_DAYS = 30
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
# Simple file-based storage (used when database is not configured)
USERS_FILE = Path("data/users.json")
USERS_FILE.parent.mkdir(exist_ok=True)
# Token blocklist: jti → expiry timestamp (Unix).
# Uses Redis when available (persistent across restarts), falls back to in-memory.
_revoked_jtis: dict[str, float] = {}
_redis_blocklist_client = None
def _get_blocklist_redis():
"""Return Redis client for token blocklist, or None if unavailable."""
global _redis_blocklist_client
if _redis_blocklist_client is not None:
return _redis_blocklist_client if _redis_blocklist_client is not False else None
redis_url = os.getenv("REDIS_URL", "")
if not redis_url:
_redis_blocklist_client = False
return None
try:
import redis as redis_lib
client = redis_lib.from_url(redis_url, decode_responses=True)
client.ping()
_redis_blocklist_client = client
logger.info("Token blocklist using Redis (persistent across restarts)")
return client
except Exception as e:
logger.warning(f"Redis unavailable for token blocklist, using in-memory: {e}")
_redis_blocklist_client = False
return None
def revoke_token_jti(jti: str, expires_at: float) -> None:
"""Add a JTI to the blocklist (revoked until its expiry time)."""
ttl = max(1, int(expires_at - time.time()))
redis = _get_blocklist_redis()
if redis:
try:
redis.setex(f"revoked_jti:{jti}", ttl, "1")
return
except Exception as e:
logger.warning(f"Redis revoke failed, falling back to memory: {e}")
_revoked_jtis[jti] = expires_at
def is_token_revoked(jti: str) -> bool:
"""Return True if JTI is revoked. Lazy GC of expired in-memory entries."""
if not jti:
return False
redis = _get_blocklist_redis()
if redis:
try:
return redis.exists(f"revoked_jti:{jti}") == 1
except Exception as e:
logger.warning(f"Redis revoke check failed, falling back to memory: {e}")
now = time.time()
expired = [k for k, v in _revoked_jtis.items() if v < now]
for k in expired:
_revoked_jtis.pop(k, None)
return jti in _revoked_jtis
def hash_password(password: str) -> str:
"""Hash a password using bcrypt or fallback to SHA256"""
@@ -91,34 +165,61 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return False
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token"""
def create_access_token(
user_id: str, tier: str = "free", expires_delta: Optional[timedelta] = None
) -> str:
"""Create a JWT access token with tier claim for quick access"""
if not JWT_AVAILABLE:
# Fallback to simple token
token_data = {
"user_id": user_id,
"exp": (datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))).isoformat()
"tier": tier,
"exp": (
datetime.now(timezone.utc)
+ (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
).isoformat(),
}
import base64
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
to_encode = {"sub": user_id, "exp": expire, "type": "access"}
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode = {
"sub": user_id,
"tier": tier,
"exp": expire,
"type": "access",
"jti": str(uuid.uuid4()),
}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(user_id: str) -> str:
"""Create a JWT refresh token"""
def create_refresh_token(
user_id: str, expires_delta: Optional[timedelta] = None
) -> str:
"""Create a JWT refresh token (7 days by default)"""
if not JWT_AVAILABLE:
token_data = {
"user_id": user_id,
"exp": (datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)).isoformat()
"exp": (
datetime.now(timezone.utc)
+ (expires_delta or timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS))
).isoformat(),
}
import base64
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {"sub": user_id, "exp": expire, "type": "refresh"}
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
)
to_encode = {
"sub": user_id,
"exp": expire,
"type": "refresh",
"jti": str(uuid.uuid4()),
}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@@ -127,20 +228,24 @@ def verify_token(token: str) -> Optional[Dict[str, Any]]:
if not JWT_AVAILABLE:
try:
import base64
data = json.loads(base64.urlsafe_b64decode(token.encode()).decode())
exp = datetime.fromisoformat(data["exp"])
if exp < datetime.utcnow():
if exp < datetime.now(timezone.utc):
return None
return {"sub": data["user_id"]}
except:
except Exception:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
jti = payload.get("jti")
if jti and is_token_revoked(jti):
return None
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.JWTError:
except jwt.PyJWTError:
return None
@@ -148,16 +253,17 @@ def load_users() -> Dict[str, Dict]:
"""Load users from file storage (JSON backend only)"""
if USERS_FILE.exists():
try:
with open(USERS_FILE, 'r') as f:
with open(USERS_FILE, "r") as f:
return json.load(f)
except:
except Exception as e:
logger.error(f"Failed to load users file: {e}")
return {}
return {}
def save_users(users: Dict[str, Dict]):
"""Save users to file storage (JSON backend only)"""
with open(USERS_FILE, 'w') as f:
with open(USERS_FILE, "w") as f:
json.dump(users, f, indent=2, default=str)
@@ -170,18 +276,21 @@ def _db_user_to_model(db_user) -> User:
password_hash=db_user.password_hash,
avatar_url=db_user.avatar_url,
plan=PlanType(db_user.plan) if db_user.plan else PlanType.FREE,
subscription_status=SubscriptionStatus(db_user.subscription_status) if db_user.subscription_status else SubscriptionStatus.ACTIVE,
subscription_status=SubscriptionStatus(db_user.subscription_status)
if db_user.subscription_status
else SubscriptionStatus.ACTIVE,
stripe_customer_id=db_user.stripe_customer_id,
stripe_subscription_id=db_user.stripe_subscription_id,
docs_translated_this_month=db_user.docs_translated_this_month or 0,
pages_translated_this_month=db_user.pages_translated_this_month or 0,
api_calls_this_month=db_user.api_calls_this_month or 0,
daily_translation_count=getattr(db_user, "daily_translation_count", 0) or 0,
extra_credits=db_user.extra_credits or 0,
usage_reset_date=db_user.usage_reset_date or datetime.utcnow(),
default_source_lang=db_user.default_source_lang or "en",
default_target_lang=db_user.default_target_lang or "es",
default_provider=db_user.default_provider or "google",
created_at=db_user.created_at or datetime.utcnow(),
usage_reset_date=db_user.usage_reset_date or datetime.now(timezone.utc),
default_source_lang=getattr(db_user, "default_source_lang", None) or "en",
default_target_lang=getattr(db_user, "default_target_lang", None) or "es",
default_provider=getattr(db_user, "default_provider", None) or "google",
created_at=db_user.created_at or datetime.now(timezone.utc),
updated_at=db_user.updated_at,
)
@@ -189,6 +298,9 @@ def _db_user_to_model(db_user) -> User:
def get_user_by_email(email: str) -> Optional[User]:
"""Get a user by email"""
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.repositories import UserRepository
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.get_by_email(email)
@@ -206,6 +318,9 @@ def get_user_by_email(email: str) -> Optional[User]:
def get_user_by_id(user_id: str) -> Optional[User]:
"""Get a user by ID"""
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.repositories import UserRepository
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.get_by_id(user_id)
@@ -224,26 +339,26 @@ def create_user(user_create: UserCreate) -> User:
# Check if email exists
if get_user_by_email(user_create.email):
raise ValueError("Email already registered")
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.repositories import UserRepository
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.create(
email=user_create.email,
name=user_create.name,
password_hash=hash_password(user_create.password),
plan=PlanType.FREE.value,
subscription_status=SubscriptionStatus.ACTIVE.value
hashed_password=hash_password(user_create.password),
tier="free",
)
session.commit()
session.refresh(db_user)
return _db_user_to_model(db_user)
else:
users = load_users()
# Generate user ID
user_id = secrets.token_urlsafe(16)
# Create user
user = User(
id=user_id,
@@ -253,11 +368,11 @@ def create_user(user_create: UserCreate) -> User:
plan=PlanType.FREE,
subscription_status=SubscriptionStatus.ACTIVE,
)
# Save to storage
users[user_id] = user.model_dump()
save_users(users)
return user
@@ -274,46 +389,55 @@ def authenticate_user(email: str, password: str) -> Optional[User]:
def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
"""Update a user's data"""
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.repositories import UserRepository
with get_sync_session() as session:
repo = UserRepository(session)
db_user = repo.update(user_id, updates)
db_user = repo.update(user_id, **updates)
if db_user:
session.commit()
session.refresh(db_user)
return _db_user_to_model(db_user)
return None
else:
users = load_users()
if user_id not in users:
return None
users[user_id].update(updates)
users[user_id]["updated_at"] = datetime.utcnow().isoformat()
users[user_id]["updated_at"] = datetime.now(timezone.utc).isoformat()
save_users(users)
return User(**users[user_id])
def check_usage_limits(user: User) -> Dict[str, Any]:
"""Check if user has exceeded their plan limits"""
plan = PLANS[user.plan]
# Reset usage if it's a new month
now = datetime.utcnow()
if user.usage_reset_date.month != now.month or user.usage_reset_date.year != now.year:
update_user(user.id, {
"docs_translated_this_month": 0,
"pages_translated_this_month": 0,
"api_calls_this_month": 0,
"usage_reset_date": now.isoformat() if not USE_DATABASE else now
})
now = datetime.now(timezone.utc)
if (
user.usage_reset_date.month != now.month
or user.usage_reset_date.year != now.year
):
update_user(
user.id,
{
"docs_translated_this_month": 0,
"pages_translated_this_month": 0,
"api_calls_this_month": 0,
"usage_reset_date": now.isoformat() if not USE_DATABASE else now,
},
)
user.docs_translated_this_month = 0
user.pages_translated_this_month = 0
user.api_calls_this_month = 0
docs_limit = plan["docs_per_month"]
docs_remaining = max(0, docs_limit - user.docs_translated_this_month) if docs_limit > 0 else -1
docs_remaining = (
max(0, docs_limit - user.docs_translated_this_month) if docs_limit > 0 else -1
)
return {
"can_translate": docs_remaining != 0 or user.extra_credits > 0,
"docs_used": user.docs_translated_this_month,
@@ -332,15 +456,15 @@ def record_usage(user_id: str, pages_count: int, use_credits: bool = False) -> b
user = get_user_by_id(user_id)
if not user:
return False
updates = {
"docs_translated_this_month": user.docs_translated_this_month + 1,
"pages_translated_this_month": user.pages_translated_this_month + pages_count,
}
if use_credits:
updates["extra_credits"] = max(0, user.extra_credits - pages_count)
result = update_user(user_id, updates)
return result is not None
@@ -350,11 +474,94 @@ def add_credits(user_id: str, credits: int) -> bool:
user = get_user_by_id(user_id)
if not user:
return False
result = update_user(user_id, {"extra_credits": user.extra_credits + credits})
return result is not None
# Valid plan values for admin tier change (Story 1.7)
VALID_PLAN_VALUES = {"free", "starter", "pro", "business", "enterprise"}
def update_user_plan(user_id: str, plan: str) -> Optional[User]:
"""
Update a user's plan/tier (admin only). Keeps User.plan and User.tier in sync.
tier is set to 'pro' for pro/business/enterprise, 'free' otherwise (DB constraint).
"""
plan_lower = (plan or "").strip().lower()
if plan_lower not in VALID_PLAN_VALUES:
return None
plan_enum = PlanType(plan_lower)
tier = (
"pro"
if plan_enum in (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE)
else "free"
)
if USE_DATABASE and DATABASE_AVAILABLE:
updates = {"plan": plan_enum, "tier": tier}
else:
updates = {"plan": plan_lower, "tier": tier}
return update_user(user_id, updates)
def get_user_by_api_key(api_key: str) -> Optional[User]:
"""
Get a user by API key.
Verifies that:
- The key exists in the database
- The key is active (is_active=True)
- The key hasn't expired (expires_at is None or in the future)
Returns the user associated with the API key, or None if invalid/revoked.
Raises:
ValueError: With code "API_KEY_REVOKED" if key exists but is inactive
"""
if not api_key:
return None
# Only database backend supports API keys
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.models import ApiKey
import hashlib
# Hash the provided key to compare with stored hash
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
with get_sync_session() as session:
api_key_record = (
session.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
)
if not api_key_record:
return None
# Check if key is active (Story 3.2 - Revocation check)
if not api_key_record.is_active:
raise ValueError("API_KEY_REVOKED")
# Check expiration if set
if api_key_record.expires_at:
if api_key_record.expires_at < datetime.now(timezone.utc):
raise ValueError("API_KEY_EXPIRED")
# Update last_used_at and usage_count
api_key_record.last_used_at = datetime.now(timezone.utc)
api_key_record.usage_count = (api_key_record.usage_count or 0) + 1
session.commit()
# Get the user
user_id = api_key_record.user_id
return get_user_by_id(str(user_id))
return None
def init_database():
"""Initialize the database (call on application startup)"""
if USE_DATABASE and DATABASE_AVAILABLE:

View File

@@ -2,16 +2,18 @@
Database-backed authentication service
Replaces JSON file storage with SQLAlchemy
"""
import os
import secrets
import hashlib
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import logging
# Try to import optional dependencies
try:
import jwt
JWT_AVAILABLE = True
except ImportError:
JWT_AVAILABLE = False
@@ -20,6 +22,7 @@ except ImportError:
try:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
PASSLIB_AVAILABLE = True
except ImportError:
@@ -65,24 +68,26 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token"""
expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
)
if not JWT_AVAILABLE:
token_data = {"user_id": user_id, "exp": expire.isoformat(), "type": "access"}
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
to_encode = {"sub": user_id, "exp": expire, "type": "access"}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(user_id: str) -> str:
"""Create a JWT refresh token"""
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
if not JWT_AVAILABLE:
token_data = {"user_id": user_id, "exp": expire.isoformat(), "type": "refresh"}
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
to_encode = {"sub": user_id, "exp": expire, "type": "refresh"}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@@ -93,12 +98,12 @@ def verify_token(token: str) -> Optional[Dict[str, Any]]:
try:
data = json.loads(base64.urlsafe_b64decode(token.encode()).decode())
exp = datetime.fromisoformat(data["exp"])
if exp < datetime.utcnow():
if exp < datetime.now(timezone.utc):
return None
return {"sub": data["user_id"], "type": data.get("type", "access")}
except Exception:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
@@ -112,18 +117,18 @@ def create_user(email: str, name: str, password: str) -> User:
"""Create a new user in the database"""
with get_db_session() as db:
repo = UserRepository(db)
# Check if email already exists
existing = repo.get_by_email(email)
if existing:
raise ValueError("Email already registered")
password_hash = hash_password(password)
hashed = hash_password(password)
user = repo.create(
email=email,
name=name,
password_hash=password_hash,
plan=PlanType.FREE,
hashed_password=hashed,
tier="free",
)
return user
@@ -133,15 +138,15 @@ def authenticate_user(email: str, password: str) -> Optional[User]:
with get_db_session() as db:
repo = UserRepository(db)
user = repo.get_by_email(email)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
# Update last login
repo.update(user.id, last_login_at=datetime.utcnow())
repo.update(user.id, last_login_at=datetime.now(timezone.utc))
return user
@@ -181,11 +186,15 @@ def use_credits(user_id: str, credits: int) -> bool:
return repo.use_credits(user_id, credits)
def increment_usage(user_id: str, docs: int = 0, pages: int = 0, api_calls: int = 0) -> bool:
def increment_usage(
user_id: str, docs: int = 0, pages: int = 0, api_calls: int = 0
) -> bool:
"""Increment user usage counters"""
with get_db_session() as db:
repo = UserRepository(db)
result = repo.increment_usage(user_id, docs=docs, pages=pages, api_calls=api_calls)
result = repo.increment_usage(
user_id, docs=docs, pages=pages, api_calls=api_calls
)
return result is not None
@@ -194,12 +203,12 @@ def check_usage_limits(user_id: str) -> Dict[str, Any]:
with get_db_session() as db:
repo = UserRepository(db)
user = repo.get_by_id(user_id)
if not user:
return {"allowed": False, "reason": "User not found"}
plan_config = PLANS.get(user.plan, PLANS[PlanType.FREE])
# Check document limit
docs_limit = plan_config["docs_per_month"]
if docs_limit > 0 and user.docs_translated_this_month >= docs_limit:
@@ -211,10 +220,12 @@ def check_usage_limits(user_id: str) -> Dict[str, Any]:
"limit": docs_limit,
"used": user.docs_translated_this_month,
}
return {
"allowed": True,
"docs_remaining": max(0, docs_limit - user.docs_translated_this_month) if docs_limit > 0 else -1,
"docs_remaining": max(0, docs_limit - user.docs_translated_this_month)
if docs_limit > 0
else -1,
"extra_credits": user.extra_credits,
}
@@ -224,22 +235,28 @@ def get_user_usage_stats(user_id: str) -> Dict[str, Any]:
with get_db_session() as db:
repo = UserRepository(db)
user = repo.get_by_id(user_id)
if not user:
return {}
plan_config = PLANS.get(user.plan, PLANS[PlanType.FREE])
return {
"docs_used": user.docs_translated_this_month,
"docs_limit": plan_config["docs_per_month"],
"docs_remaining": max(0, plan_config["docs_per_month"] - user.docs_translated_this_month) if plan_config["docs_per_month"] > 0 else -1,
"docs_remaining": max(
0, plan_config["docs_per_month"] - user.docs_translated_this_month
)
if plan_config["docs_per_month"] > 0
else -1,
"pages_used": user.pages_translated_this_month,
"extra_credits": user.extra_credits,
"max_pages_per_doc": plan_config["max_pages_per_doc"],
"max_file_size_mb": plan_config["max_file_size_mb"],
"allowed_providers": plan_config["providers"],
"api_access": plan_config.get("api_access", False),
"api_calls_used": user.api_calls_this_month if plan_config.get("api_access") else 0,
"api_calls_used": user.api_calls_this_month
if plan_config.get("api_access")
else 0,
"api_calls_limit": plan_config.get("api_calls_per_month", 0),
}

View File

@@ -0,0 +1,183 @@
"""
Glossary Service for Translation
Story 3.10: Glossaires - Application lors Traduction LLM
Provides functions to retrieve glossary terms and format them for LLM prompts.
"""
import logging
from typing import List, Dict, Any, Optional
from database.connection import get_sync_session
from database.models import Glossary, GlossaryTerm
from utils.exceptions import GlossaryNotFoundError
logger = logging.getLogger(__name__)
def get_glossary_terms(glossary_id: str, user_id: str) -> List[Dict[str, str]]:
"""
Retrieve glossary terms for a specific glossary owned by a user.
Args:
glossary_id: UUID of the glossary
user_id: UUID of the user (must own the glossary)
Returns:
List of dictionaries with 'source' and 'target' keys
Raises:
GlossaryNotFoundError: If glossary doesn't exist or doesn't belong to user
"""
try:
with get_sync_session() as session:
glossary = (
session.query(Glossary)
.filter(Glossary.id == glossary_id, Glossary.user_id == user_id)
.first()
)
if not glossary:
raise GlossaryNotFoundError(
message="Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
details={"glossary_id": glossary_id}
)
# Get all terms for this glossary
terms = (
session.query(GlossaryTerm)
.filter(GlossaryTerm.glossary_id == glossary_id)
.all()
)
# Format as list of dicts
result = [{"source": term.source, "target": term.target} for term in terms]
logger.info(
f"Retrieved {len(result)} terms from glossary {glossary_id} for user {user_id}"
)
return result
except GlossaryNotFoundError:
raise
except Exception as e:
logger.error(f"Error retrieving glossary {glossary_id}: {e}")
raise GlossaryNotFoundError(
message="Erreur lors de la récupération du glossaire.",
details={"glossary_id": glossary_id, "error": str(e)}
)
def validate_glossary_access(glossary_id: str, user_id: str) -> bool:
"""
Validate that a glossary exists and belongs to the user.
This is a lightweight check that doesn't return the terms,
useful for early validation before starting a translation job.
Args:
glossary_id: UUID of the glossary
user_id: UUID of the user (must own the glossary)
Returns:
True if glossary exists and belongs to user
Raises:
GlossaryNotFoundError: If glossary doesn't exist or doesn't belong to user
"""
try:
with get_sync_session() as session:
glossary = (
session.query(Glossary)
.filter(Glossary.id == glossary_id, Glossary.user_id == user_id)
.first()
)
if not glossary:
raise GlossaryNotFoundError(
message="Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
details={"glossary_id": glossary_id}
)
return True
except GlossaryNotFoundError:
raise
except Exception as e:
logger.error(f"Error validating glossary access {glossary_id}: {e}")
raise GlossaryNotFoundError(
message="Erreur lors de la validation du glossaire.",
details={"glossary_id": glossary_id, "error": str(e)}
)
def format_glossary_for_prompt(terms: List[Dict[str, str]]) -> str:
"""
Format glossary terms for injection into an LLM system prompt.
The format is designed to be clear and unambiguous for LLMs:
- Clear header explaining the purpose
- Simple source → target format
- Explicit instruction to use these translations
Args:
terms: List of dictionaries with 'source' and 'target' keys
Returns:
Formatted string for LLM prompt
"""
if not terms:
return ""
# Sort terms by length (longest first) to avoid substring conflicts
# e.g., "machine learning" should match before "machine"
sorted_terms = sorted(terms, key=lambda t: len(t.get("source", "")), reverse=True)
lines = [
"TERMINOLOGY GLOSSARY (use these exact translations):",
""
]
for term in sorted_terms:
source = term.get("source", "").strip()
target = term.get("target", "").strip()
if source and target:
# Escape single quotes in terms for clarity
source_escaped = source.replace("'", "\\'")
target_escaped = target.replace("'", "\\'")
lines.append(f"- '{source_escaped}''{target_escaped}'")
lines.extend([
"",
"IMPORTANT: Always use these translations when the terms appear in the text."
])
return "\n".join(lines)
def build_full_prompt(
custom_prompt: Optional[str],
glossary_terms: Optional[List[Dict[str, str]]]
) -> str:
"""
Build the complete prompt combining custom prompt and glossary.
Args:
custom_prompt: Optional custom system prompt from user
glossary_terms: Optional list of glossary terms
Returns:
Combined prompt string
"""
parts = []
if custom_prompt:
parts.append(custom_prompt)
if glossary_terms:
glossary_prompt = format_glossary_for_prompt(glossary_terms)
if glossary_prompt:
parts.append(glossary_prompt)
return "\n\n".join(parts) if parts else ""

View File

@@ -0,0 +1,174 @@
"""
Progress Tracker Service (Story 2.11)
Provides real-time progress tracking for translation jobs.
Designed for O(1) updates and < 500ms latency (NFR3).
"""
from typing import Dict, Any, Optional, Callable
import threading
import time
class ProgressTracker:
"""
Track translation progress with callback support.
Designed for high-performance updates with minimal overhead.
Uses in-memory storage for MVP (consistent with Story 2.10 pattern).
Usage:
storage = {} # Reference to _translation_jobs dict
tracker = ProgressTracker("job_123", storage)
tracker.update(50, "Translating sheet 2/4")
# Or use item-based progress
tracker.update_item(3, 10, "Translating slide")
"""
def __init__(self, job_id: str, storage: Dict[str, Any]):
"""
Initialize progress tracker.
Args:
job_id: The translation job ID
storage: Reference to the job storage dict (e.g., _translation_jobs)
"""
self.job_id = job_id
self.storage = storage
self._lock = threading.RLock()
self._last_update_time = 0
self._min_update_interval = 0.05 # 50ms minimum between updates (throttling)
def update(self, percent: int, step: str) -> None:
"""
Update progress percentage and current step.
Thread-safe and throttled to prevent excessive updates.
Args:
percent: Progress percentage (0-100), will be clamped
step: Human-readable description of current operation
"""
with self._lock:
current_time = time.time()
if current_time - self._last_update_time < self._min_update_interval:
if percent < 100:
return
job = self.storage.get(self.job_id)
if job:
# Never decrease progress — only move forward.
new_percent = min(100, max(0, percent))
job["progress_percent"] = max(job.get("progress_percent", 0), new_percent)
job["current_step"] = step
job["processed_items"] = job.get("processed_items", 0)
job["total_items"] = job.get("total_items", 0)
self._last_update_time = current_time
def update_item(
self, current: int, total: int, item_name: str, max_percent: int = 100
) -> None:
"""
Update progress based on item count (e.g., slides, sheets).
Calculates percentage from current/total and formats step message.
Args:
current: Current item number (1-based)
total: Total number of items
item_name: Name of item type (e.g., "Translating slide", "Processing sheet")
max_percent: Upper bound for the computed percentage (default 100).
Use 95 to reserve the last 5% for file-save + set_completed().
"""
percent = int((current / total) * 100) if total > 0 else 0
percent = min(percent, max_percent)
step = f"{item_name} {current}/{total}"
with self._lock:
current_time = time.time()
if current_time - self._last_update_time < self._min_update_interval:
if percent < 100:
return
job = self.storage.get(self.job_id)
if job:
# Never decrease progress — only move forward.
new_percent = min(100, max(0, percent))
job["progress_percent"] = max(job.get("progress_percent", 0), new_percent)
job["current_step"] = step
job["processed_items"] = current
job["total_items"] = total
self._last_update_time = current_time
def set_error(
self, error_message: str, step: str = "Error during translation"
) -> None:
"""
Mark job as failed with error message.
Args:
error_message: Description of the error
step: Current step description (default: "Error during translation")
"""
with self._lock:
job = self.storage.get(self.job_id)
if job:
job["status"] = "failed"
job["error_message"] = error_message
job["current_step"] = step
job["failed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def set_completed(self, output_path: Optional[str] = None) -> None:
"""
Mark job as completed.
Args:
output_path: Optional path to the output file
"""
with self._lock:
job = self.storage.get(self.job_id)
if job:
job["status"] = "completed"
job["progress_percent"] = 100
job["current_step"] = "Translation complete"
job["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
if output_path:
job["output_path"] = str(output_path)
def create_progress_callback(
tracker: ProgressTracker, item_name: str, total_items: int
) -> Callable[[Dict[str, Any]], None]:
"""
Create a progress callback function for use with translators.
Args:
tracker: ProgressTracker instance
item_name: Name of item being processed (e.g., "Translating slide")
total_items: Total number of items
Returns:
Callback function compatible with translator progress_callback parameter
"""
def callback(progress_info: Dict[str, Any]) -> None:
"""Progress callback that updates the tracker."""
# Extract item number from progress_info dict
# Different translators use different keys
current = progress_info.get(
"slide",
progress_info.get(
"sheet", progress_info.get("paragraph", progress_info.get("element", 1))
),
)
total = progress_info.get(
"total_slides",
progress_info.get(
"total", progress_info.get("total_paragraphs", total_items)
),
)
tracker.update_item(current, total, item_name)
return callback

127
services/prompt_service.py Normal file
View File

@@ -0,0 +1,127 @@
"""
Prompt Service for Translation
Story 3.12: Custom Prompts - Application lors Traduction LLM
Provides functions to retrieve prompt content and validate access.
"""
import uuid
import logging
from typing import Optional, Tuple
from database.connection import get_sync_session
from database.models import CustomPrompt
from utils.exceptions import PromptNotFoundError
logger = logging.getLogger(__name__)
def _validate_uuid(id_str: str, id_name: str = "ID") -> None:
"""
Validate that a string is a valid UUID.
Args:
id_str: String to validate
id_name: Name of the ID for error messages
Raises:
PromptNotFoundError: If the string is not a valid UUID
"""
try:
uuid.UUID(id_str)
except (ValueError, AttributeError):
raise PromptNotFoundError(
message=f"{id_name} invalide.",
details={id_name.lower(): id_str}
)
def _get_prompt_record(prompt_id: str, user_id: str) -> Tuple[CustomPrompt, bool]:
"""
Internal helper to fetch a prompt record from the database.
This is a shared function to avoid code duplication between
get_prompt_content and validate_prompt_access.
Args:
prompt_id: UUID of the prompt
user_id: UUID of the user (must own the prompt)
Returns:
Tuple of (CustomPrompt, was_logged) - was_logged indicates if access was already logged
Raises:
PromptNotFoundError: If prompt doesn't exist or doesn't belong to user
"""
# Validate UUIDs before querying database
_validate_uuid(prompt_id, "prompt_id")
_validate_uuid(user_id, "user_id")
try:
with get_sync_session() as session:
prompt = (
session.query(CustomPrompt)
.filter(CustomPrompt.id == prompt_id, CustomPrompt.user_id == user_id)
.first()
)
if not prompt:
raise PromptNotFoundError(
message="Prompt introuvable ou vous n'avez pas accès à cette ressource.",
details={"prompt_id": prompt_id}
)
return prompt, False
except PromptNotFoundError:
raise
except Exception as e:
logger.error(f"Error fetching prompt {prompt_id}: {e}")
raise PromptNotFoundError(
message="Erreur lors de la récupération du prompt.",
details={"prompt_id": prompt_id, "error": str(e)}
)
def get_prompt_content(prompt_id: str, user_id: str) -> str:
"""
Retrieve prompt content for a specific prompt owned by a user.
Args:
prompt_id: UUID of the prompt
user_id: UUID of the user (must own the prompt)
Returns:
The prompt content string
Raises:
PromptNotFoundError: If prompt doesn't exist or doesn't belong to user
"""
prompt, _ = _get_prompt_record(prompt_id, user_id)
logger.info(
f"Retrieved prompt '{prompt.name}' ({prompt_id}) for user {user_id}"
)
return prompt.content
def validate_prompt_access(prompt_id: str, user_id: str) -> bool:
"""
Validate that a prompt exists and belongs to the user.
Lightweight check before starting a translation job.
Does NOT log to avoid duplicate log entries when followed by get_prompt_content.
Args:
prompt_id: UUID of the prompt
user_id: UUID of the user (must own the prompt)
Returns:
True if prompt exists and belongs to user
Raises:
PromptNotFoundError: If prompt doesn't exist or doesn't belong to user
"""
_get_prompt_record(prompt_id, user_id)
return True

View File

@@ -0,0 +1,282 @@
# Translation Providers
This directory contains translation provider implementations for the office_translator service.
## Available Providers
### Google Translate (`google_provider.py`)
Production-ready Google Translate provider with:
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Health check with result caching (60s TTL)
- Usage metrics logging
**Configuration:**
```bash
GOOGLE_TRANSLATE_ENABLED=true
GOOGLE_TRANSLATE_TIMEOUT=30
GOOGLE_TRANSLATE_MAX_RETRIES=3
GOOGLE_TRANSLATE_RETRY_DELAY=1
```
**API Usage:**
- Free tier: 500,000 characters/month
- 5,000 characters max per request
- Cost: ~$20 per million characters (paid tier)
**Error Codes:**
| Code | Description |
|------|-------------|
| `GOOGLE_QUOTA_EXCEEDED` | API quota exceeded (429) |
| `GOOGLE_INVALID_KEY` | Invalid API key (401/403) |
| `GOOGLE_NETWORK_ERROR` | Network/timeout error (502) |
| `GOOGLE_UNSUPPORTED_LANGUAGE` | Language not supported (400) |
| `GOOGLE_TEXT_TOO_LONG` | Text exceeds 5000 chars (413) |
### DeepL (`deepl_provider.py`)
Production-ready DeepL provider with:
- Automatic Free/Pro endpoint detection based on API key format
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Health check with result caching (60s TTL)
- Language code normalization for DeepL compatibility
**Configuration:**
```bash
DEEPL_ENABLED=true
DEEPL_API_KEY=your_deepl_api_key_here # Free keys end with :fx
DEEPL_TIMEOUT=30
DEEPL_MAX_RETRIES=3
DEEPL_RETRY_DELAY=1
```
**Free vs Pro API Keys:**
| Type | Key Format | Endpoint |
|------|------------|----------|
| Free | Ends with `:fx` | `https://api-free.deepl.com/v2/translate` |
| Pro | Does NOT end with `:fx` | `https://api.deepl.com/v2/translate` |
**API Usage:**
- Free tier: 500,000 characters/month
- Pro tier: ~€25 per million characters
- 128KB max per request
- Higher quality for European languages
**Supported Languages:**
BG, CS, DA, DE, EL, EN-GB, EN-US, ES, ET, FI, FR, HU, ID, IT, JA, KO, LT, LV, NB, NL, PL, PT-BR, PT-PT, RO, RU, SK, SL, SV, TR, UK, ZH
**Language Notes:**
- English has two variants: EN-GB, EN-US (defaults to EN-US)
- Portuguese has two variants: PT-BR, PT-PT (defaults to PT-BR)
- Language codes are case-sensitive (uppercase)
- Auto-detect uses `auto` (like Google)
**Error Codes:**
| Code | HTTP | Description |
|------|------|-------------|
| `DEEPL_QUOTA_EXCEEDED` | 429 | Character quota exceeded |
| `DEEPL_INVALID_KEY` | 401 | Invalid API key |
| `DEEPL_NETWORK_ERROR` | 502 | Network/timeout error |
| `DEEPL_UNSUPPORTED_LANGUAGE` | 400 | Language not supported |
| `DEEPL_TEXT_TOO_LONG` | 413 | Text exceeds 128KB |
### OpenAI (`openai_provider.py`)
Cloud LLM translation provider with:
- GPT-4/GPT-4o/GPT-4o-mini model support
- Custom system prompt support for translation context
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Fast timeout for cloud API (default 60s)
- Health check with result caching (60s TTL)
**Configuration:**
```bash
OPENAI_ENABLED=true
OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_MODEL=gpt-4o-mini
OPENAI_TIMEOUT=60
OPENAI_MAX_RETRIES=3
OPENAI_RETRY_DELAY=1.0
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: for Azure OpenAI or proxies
```
**Prerequisites:**
- OpenAI API key from https://platform.openai.com/api-keys
- Valid billing method on your OpenAI account
**Recommended Models for Translation:**
| Model | Cost | Speed | Quality | Best For |
|-------|------|-------|---------|----------|
| `gpt-4o-mini` | $0.15/M tokens | Fast | Good | Default choice, cost-effective |
| `gpt-4o` | $2.50/M tokens | Medium | Excellent | High-quality requirements |
| `gpt-4` | $30/M tokens | Slower | Excellent | Critical translations |
| `gpt-3.5-turbo` | $0.50/M tokens | Fastest | Good | Speed priority |
**Custom System Prompt:**
```python
request = TranslationRequest(
text="Hello",
target_language="fr",
metadata={"custom_prompt": "Translate formally for business context"}
)
```
**Rate Limiting:**
- OpenAI has strict rate limits per tier
- The provider automatically handles 429 errors with retry
- Retry-After header is respected when available
- Exponential backoff for transient errors
**Error Codes:**
| Code | HTTP | Description |
|------|------|-------------|
| `OPENAI_RATE_LIMITED` | 429 | Rate limit hit, retry suggested |
| `OPENAI_INVALID_KEY` | 401 | Invalid API key |
| `OPENAI_QUOTA_EXCEEDED` | 429 | Billing quota exceeded |
| `OPENAI_TIMEOUT` | 502 | Request timeout |
| `OPENAI_SERVICE_ERROR` | 502 | OpenAI server error |
| `OPENAI_CONTEXT_TOO_LONG` | 413 | Text exceeds model limit |
### Ollama (`ollama_provider.py`)
Local LLM translation provider with:
- Custom system prompt support for translation context
- Automatic model availability checking
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Longer timeout for LLM operations (default 120s)
- Health check with result caching (60s TTL)
**Configuration:**
```bash
OLLAMA_ENABLED=true
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3
OLLAMA_VISION_MODEL=llava
OLLAMA_TIMEOUT=120
OLLAMA_MAX_RETRIES=2
OLLAMA_RETRY_DELAY=2
```
**Prerequisites:**
- Ollama must be installed and running: `ollama serve`
- Model must be pulled before use: `ollama pull llama3`
**Recommended Models for Translation:**
| Model | Size | Best For |
|-------|------|----------|
| `llama3` | 8B | General translation, good balance |
| `llama3:70b` | 70B | High-quality translation |
| `mistral` | 7B | Fast translation |
| `qwen2` | 7B | Strong non-English support |
**Custom System Prompt:**
```python
request = TranslationRequest(
text="Hello",
target_language="fr",
metadata={"custom_prompt": "Translate formally for business context"}
)
```
**Error Codes:**
| Code | HTTP | Description |
|------|------|-------------|
| `OLLAMA_UNAVAILABLE` | 502 | Ollama service not reachable |
| `OLLAMA_MODEL_NOT_FOUND` | 400 | Model not pulled |
| `OLLAMA_TIMEOUT` | 502 | Request timeout |
| `OLLAMA_GENERATION_ERROR` | 502 | LLM generation failed |
| `OLLAMA_CONTEXT_TOO_LONG` | 413 | Text exceeds model limit |
## Usage
```python
from services.providers.google_provider import GoogleTranslationProvider
from services.providers.deepl_provider import DeepLTranslationProvider
from services.providers.openai_provider import OpenAITranslationProvider
from services.providers.ollama_provider import OllamaTranslationProvider
from services.providers.schemas import TranslationRequest
# Google provider
google_provider = GoogleTranslationProvider()
request = TranslationRequest(text="Hello", target_language="fr")
response = google_provider.translate_text(request)
# DeepL provider (requires API key)
deepl_provider = DeepLTranslationProvider(api_key="your-key:fx")
request = TranslationRequest(text="Hello", target_language="fr")
response = deepl_provider.translate_text(request)
# OpenAI provider (requires API key)
openai_provider = OpenAITranslationProvider(
api_key="sk-...",
model="gpt-4o-mini"
)
request = TranslationRequest(text="Hello", target_language="fr")
response = openai_provider.translate_text(request)
# OpenAI with custom prompt
request = TranslationRequest(
text="Hello",
target_language="fr",
metadata={"custom_prompt": "Translate formally for business context"}
)
response = openai_provider.translate_text(request)
# Ollama provider (requires local Ollama running)
ollama_provider = OllamaTranslationProvider(
base_url="http://localhost:11434",
model="llama3"
)
request = TranslationRequest(text="Hello", target_language="fr")
response = ollama_provider.translate_text(request)
# Ollama with custom prompt
request = TranslationRequest(
text="Hello",
target_language="fr",
metadata={"custom_prompt": "Translate formally"}
)
response = ollama_provider.translate_text(request)
if response.success:
print(response.translated_text)
else:
print(f"Error: {response.error_code} - {response.error}")
```
## Registry Usage
```python
from services.providers import registry
# List all providers
print(registry.list_all())
# Get first available from fallback chain
provider = registry.get_first_available(["google", "deepl", "openai", "ollama"])
# Check if provider is available
print(registry.list_available())
```
## Health Check
```python
status = provider.health_check()
print(f"Available: {status.available}")
print(f"Latency: {status.latency_ms}ms")
print(f"Last Check: {status.last_check}")
```
## Architecture
All providers extend `TranslationProvider` base class and implement:
- `translate_text(request: TranslationRequest) -> TranslationResponse`
- `translate_batch(requests: List[TranslationRequest]) -> List[TranslationResponse]`
- `is_available() -> bool`
- `health_check() -> ProviderHealthStatus`
- `get_name() -> str`

View File

@@ -0,0 +1,81 @@
"""
Translation Providers Package.
This package provides a pluggable architecture for translation providers
with a registry for easy access and fallback support.
Usage:
from services.providers import TranslationProvider, registry
from services.providers.schemas import TranslationRequest, TranslationResponse
# Get a provider (Google is auto-registered)
google_provider = registry.get("google")
# Translate text
request = TranslationRequest(text="Hello", target_language="fr")
response = google_provider.translate_text(request)
# Use fallback chain
provider = registry.get_first_available(["google", "deepl", "openai"])
"""
from .base import TranslationProvider
from .schemas import (
TranslationRequest,
TranslationResponse,
BatchTranslationRequest,
BatchTranslationResponse,
ProviderHealthStatus,
)
from .registry import ProviderRegistry, registry, get_registry
__all__ = [
"TranslationProvider",
"TranslationRequest",
"TranslationResponse",
"BatchTranslationRequest",
"BatchTranslationResponse",
"ProviderHealthStatus",
"ProviderRegistry",
"registry",
"get_registry",
"translate_with_fallback",
"translate_with_fallback_by_mode",
"AllProvidersFailedError",
"ALL_PROVIDERS_FAILED",
]
def _auto_register_providers() -> None:
"""Auto-register available providers on module import."""
from .google_provider import register_google_provider
from .config import ProvidersConfig
if ProvidersConfig.GOOGLE_ENABLED:
register_google_provider()
if ProvidersConfig.DEEPL_ENABLED and ProvidersConfig.DEEPL_API_KEY:
from .deepl_provider import register_deepl_provider
register_deepl_provider()
if ProvidersConfig.OLLAMA_ENABLED:
from .ollama_provider import register_ollama_provider
register_ollama_provider()
if ProvidersConfig.OPENAI_ENABLED and ProvidersConfig.OPENAI_API_KEY:
from .openai_provider import register_openai_provider
register_openai_provider()
_auto_register_providers()
# Import fallback functions for easy access
from .fallback import (
translate_with_fallback,
translate_with_fallback_by_mode,
AllProvidersFailedError,
ALL_PROVIDERS_FAILED,
)

104
services/providers/base.py Normal file
View File

@@ -0,0 +1,104 @@
"""
Abstract base class for translation providers.
Provides a common interface for all translation provider implementations.
"""
from abc import ABC, abstractmethod
from typing import Optional, List
import time
from .schemas import (
TranslationRequest,
TranslationResponse,
BatchTranslationRequest,
BatchTranslationResponse,
ProviderHealthStatus,
)
class TranslationProvider(ABC):
"""
Abstract base class for translation providers.
All translation providers must implement this interface to ensure
consistent behavior across different translation services.
"""
@abstractmethod
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
"""
Translate a single text string.
Args:
request: TranslationRequest containing text, target_language, and source_language
Returns:
TranslationResponse with translated text and metadata
"""
pass
@abstractmethod
def get_name(self) -> str:
"""
Return the provider name for logging and registry.
Returns:
Provider name as a string (e.g., "google", "deepl", "openai")
"""
pass
@abstractmethod
def is_available(self) -> bool:
"""
Check if the provider is configured and reachable.
Returns:
True if the provider can perform translations, False otherwise
"""
pass
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
"""
Translate multiple texts. Default implementation uses individual calls.
Subclasses can override this for optimized batch processing.
Args:
requests: List of TranslationRequest objects
Returns:
List of TranslationResponse objects in the same order as requests
"""
return [self.translate_text(req) for req in requests]
def health_check(self) -> ProviderHealthStatus:
"""
Return health status details for the provider.
Performs a lightweight check to verify the provider is operational.
Returns:
ProviderHealthStatus with availability and latency information
"""
start_time = time.time()
try:
available = self.is_available()
latency_ms = (time.time() - start_time) * 1000
return ProviderHealthStatus(
name=self.get_name(),
available=available,
latency_ms=round(latency_ms, 2),
error=None if available else "Provider not available",
)
except Exception as e:
latency_ms = (time.time() - start_time) * 1000
return ProviderHealthStatus(
name=self.get_name(),
available=False,
latency_ms=round(latency_ms, 2),
error=str(e),
)

View File

@@ -0,0 +1,208 @@
"""
Provider Configuration - Environment-based settings for translation providers.
Loads API keys, URLs, and enable/disable flags from environment variables.
"""
import os
from typing import List, Optional
from pydantic import BaseModel
def _ensure_dotenv_loaded() -> None:
"""Load .env file if not already loaded."""
from dotenv import load_dotenv
load_dotenv()
_ensure_dotenv_loaded()
class ProviderSettings(BaseModel):
"""Settings for a single translation provider."""
enabled: bool = False
api_key: Optional[str] = None
base_url: Optional[str] = None
model: Optional[str] = None
class ProvidersConfig:
"""
Configuration for all translation providers.
Loads settings from environment variables with sensible defaults.
"""
# Google Translate (no API key required via deep_translator)
GOOGLE_ENABLED: bool = (
os.getenv("GOOGLE_TRANSLATE_ENABLED", "true").lower() == "true"
)
GOOGLE_TRANSLATE_TIMEOUT: int = int(os.getenv("GOOGLE_TRANSLATE_TIMEOUT", "30"))
GOOGLE_TRANSLATE_MAX_RETRIES: int = int(
os.getenv("GOOGLE_TRANSLATE_MAX_RETRIES", "3")
)
GOOGLE_TRANSLATE_RETRY_DELAY: float = float(
os.getenv("GOOGLE_TRANSLATE_RETRY_DELAY", "1.0")
)
# DeepL
DEEPL_ENABLED: bool = os.getenv("DEEPL_ENABLED", "false").lower() == "true"
DEEPL_API_KEY: str = os.getenv("DEEPL_API_KEY", "")
DEEPL_TIMEOUT: int = int(os.getenv("DEEPL_TIMEOUT", "30"))
DEEPL_MAX_RETRIES: int = int(os.getenv("DEEPL_MAX_RETRIES", "3"))
DEEPL_RETRY_DELAY: float = float(os.getenv("DEEPL_RETRY_DELAY", "1.0"))
# OpenAI
OPENAI_ENABLED: bool = os.getenv("OPENAI_ENABLED", "false").lower() == "true"
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
OPENAI_MODEL: str = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
OPENAI_TIMEOUT: int = int(os.getenv("OPENAI_TIMEOUT", "60"))
OPENAI_MAX_RETRIES: int = int(os.getenv("OPENAI_MAX_RETRIES", "3"))
OPENAI_RETRY_DELAY: float = float(os.getenv("OPENAI_RETRY_DELAY", "1.0"))
OPENAI_BASE_URL: str = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
OPENAI_HEALTH_CHECK_TIMEOUT: int = int(
os.getenv("OPENAI_HEALTH_CHECK_TIMEOUT", "5")
)
# Ollama (local LLM) - default model is config-only, no hardcode in provider
_DEFAULT_OLLAMA_MODEL: str = "llama3"
OLLAMA_ENABLED: bool = os.getenv("OLLAMA_ENABLED", "false").lower() == "true"
OLLAMA_BASE_URL: str = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", _DEFAULT_OLLAMA_MODEL)
OLLAMA_VISION_MODEL: str = os.getenv("OLLAMA_VISION_MODEL", "llava")
OLLAMA_TIMEOUT: int = int(os.getenv("OLLAMA_TIMEOUT", "120"))
OLLAMA_MAX_RETRIES: int = int(os.getenv("OLLAMA_MAX_RETRIES", "2"))
OLLAMA_RETRY_DELAY: float = float(os.getenv("OLLAMA_RETRY_DELAY", "2.0"))
# OpenRouter (multi-model API)
OPENROUTER_ENABLED: bool = (
os.getenv("OPENROUTER_ENABLED", "false").lower() == "true"
)
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-chat")
# Fallback chain configuration
# General fallback chain (backward compatibility)
FALLBACK_CHAIN: List[str] = [
name.strip()
for name in os.getenv(
"PROVIDER_FALLBACK_CHAIN", "google,deepl,openai,ollama,openrouter"
).split(",")
if name.strip()
]
# Mode-specific fallback chains
# Classic mode: Google Translate -> DeepL
FALLBACK_CHAIN_CLASSIC: List[str] = [
name.strip()
for name in os.getenv("FALLBACK_CHAIN_CLASSIC", "google,deepl").split(",")
if name.strip()
]
# LLM mode: Ollama (local) -> OpenAI (cloud)
FALLBACK_CHAIN_LLM: List[str] = [
name.strip()
for name in os.getenv("FALLBACK_CHAIN_LLM", "ollama,openai").split(",")
if name.strip()
]
@classmethod
def get_fallback_chain(cls, mode: str = "auto") -> List[str]:
"""
Get the fallback chain for a specific mode.
Args:
mode: "classic" for Classic providers, "llm" for LLM providers,
"auto" or any other value for general fallback chain
Returns:
List of provider names in fallback order
"""
mode = mode.lower()
if mode == "classic":
return cls.FALLBACK_CHAIN_CLASSIC
elif mode == "llm":
return cls.FALLBACK_CHAIN_LLM
else:
return cls.FALLBACK_CHAIN
@classmethod
def get_provider_settings(cls, provider_name: str) -> ProviderSettings:
"""
Get settings for a specific provider.
Args:
provider_name: Name of the provider (e.g., "google", "deepl")
Returns:
ProviderSettings for the requested provider
"""
settings_map = {
"google": ProviderSettings(
enabled=cls.GOOGLE_ENABLED, api_key=None, base_url=None, model=None
),
"deepl": ProviderSettings(
enabled=cls.DEEPL_ENABLED,
api_key=cls.DEEPL_API_KEY if cls.DEEPL_API_KEY else None,
base_url=None,
model=None,
),
"openai": ProviderSettings(
enabled=cls.OPENAI_ENABLED,
api_key=cls.OPENAI_API_KEY if cls.OPENAI_API_KEY else None,
base_url=cls.OPENAI_BASE_URL or None,
model=cls.OPENAI_MODEL,
),
"ollama": ProviderSettings(
enabled=cls.OLLAMA_ENABLED,
api_key=None,
base_url=cls.OLLAMA_BASE_URL,
model=cls.OLLAMA_MODEL,
),
"openrouter": ProviderSettings(
enabled=cls.OPENROUTER_ENABLED,
api_key=cls.OPENROUTER_API_KEY if cls.OPENROUTER_API_KEY else None,
base_url="https://openrouter.ai/api/v1",
model=cls.OPENROUTER_MODEL,
),
}
return settings_map.get(provider_name.lower(), ProviderSettings())
@classmethod
def is_provider_configured(cls, provider_name: str) -> bool:
"""
Check if a provider is properly configured.
Args:
provider_name: Name of the provider
Returns:
True if the provider is enabled and has required configuration
"""
settings = cls.get_provider_settings(provider_name)
if not settings.enabled:
return False
# Providers requiring API keys
providers_requiring_key = {"deepl", "openai", "openrouter"}
if provider_name.lower() in providers_requiring_key:
return bool(settings.api_key)
return True
@classmethod
def get_available_providers(cls) -> List[str]:
"""
Get list of configured and available providers.
Returns:
List of provider names that are ready to use
"""
return [name for name in cls.FALLBACK_CHAIN if cls.is_provider_configured(name)]
providers_config = ProvidersConfig()

View File

@@ -0,0 +1,763 @@
"""
DeepL Provider - Production-ready implementation.
Extends TranslationProvider base class with robust error handling,
retry logic, and health monitoring.
Features:
- Automatic Free/Pro endpoint detection based on API key format
- Specific error codes for all DeepL API errors
- Retry logic with exponential backoff for transient errors
- Timeout configuration
- Health check with caching
- Structlog-compatible logging (no document content in logs)
"""
import os
import socket
import threading
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
try:
import structlog
_HAS_STRUCTLOG = True
logger = structlog.get_logger(__name__)
except ImportError:
import logging
_HAS_STRUCTLOG = False
logger = logging.getLogger(__name__)
def _log_info(event: str, **kwargs):
"""Log info message compatible with both structlog and standard logging."""
if _HAS_STRUCTLOG:
logger.info(event, **kwargs)
else:
logger.info(f"{event} {' '.join(f'{k}={v}' for k, v in kwargs.items())}")
def _log_warning(event: str, **kwargs):
"""Log warning message compatible with both structlog and standard logging."""
if _HAS_STRUCTLOG:
logger.warning(event, **kwargs)
else:
logger.warning(f"{event} {' '.join(f'{k}={v}' for k, v in kwargs.items())}")
def _log_error(event: str, **kwargs):
"""Log error message compatible with both structlog and standard logging."""
if _HAS_STRUCTLOG:
logger.error(event, **kwargs)
else:
logger.error(f"{event} {' '.join(f'{k}={v}' for k, v in kwargs.items())}")
from .base import TranslationProvider
from .schemas import (
BatchTranslationRequest,
BatchTranslationResponse,
ProviderHealthStatus,
TranslationRequest,
TranslationResponse,
)
DEEPL_QUOTA_EXCEEDED = "DEEPL_QUOTA_EXCEEDED"
DEEPL_INVALID_KEY = "DEEPL_INVALID_KEY"
DEEPL_NETWORK_ERROR = "DEEPL_NETWORK_ERROR"
DEEPL_UNSUPPORTED_LANGUAGE = "DEEPL_UNSUPPORTED_LANGUAGE"
DEEPL_TEXT_TOO_LONG = "DEEPL_TEXT_TOO_LONG"
_RETRYABLE_ERRORS = {DEEPL_NETWORK_ERROR, DEEPL_QUOTA_EXCEEDED}
DEEPL_FREE_SUFFIX = ":fx"
MAX_TEXT_LENGTH = 128 * 1024
DEEPL_SUPPORTED_LANGUAGES = {
"BG",
"CS",
"DA",
"DE",
"EL",
"EN-GB",
"EN-US",
"ES",
"ET",
"FI",
"FR",
"HU",
"ID",
"IT",
"JA",
"KO",
"LT",
"LV",
"NB",
"NL",
"PL",
"PT-BR",
"PT-PT",
"RO",
"RU",
"SK",
"SL",
"SV",
"TR",
"UK",
"ZH",
}
class DeepLProviderError(Exception):
"""Exception raised for DeepL API errors."""
def __init__(
self, code: str, message: str, details: Optional[Dict[str, Any]] = None
):
self.code = code
self.message = message
self.details = details or {}
super().__init__(message)
def to_dict(self) -> Dict[str, Any]:
"""Convert error to dictionary format."""
result = {
"error": self.code,
"message": self.message,
}
if self.details:
result["details"] = self.details
return result
class DeepLTranslationProvider(TranslationProvider):
"""
DeepL implementation using deep_translator library.
Features:
- Automatic Free/Pro endpoint detection based on API key format
- Thread-safe translator instances per thread
- Caching support (uses global cache from translation_service)
- Batch translation with optimized processing
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Configurable timeout
- Health check with result caching
"""
def __init__(
self,
api_key: str,
use_cache: bool = True,
timeout: int = 30,
max_retries: int = 3,
retry_delay: float = 1.0,
):
"""
Initialize DeepL provider.
Args:
api_key: DeepL API key (Free keys end with :fx)
use_cache: Whether to use translation caching (default: True)
timeout: Request timeout in seconds (default: 30)
max_retries: Maximum retry attempts for transient errors (default: 3)
retry_delay: Initial retry delay in seconds (default: 1.0)
"""
if not api_key:
raise ValueError("DeepL API key is required")
self._api_key = api_key
self._api_type = self._detect_api_type(api_key)
self._local = threading.local()
self._use_cache = use_cache
self._provider_name = "deepl"
self._cache = None
self.timeout = timeout
self.max_retries = max_retries
self.retry_delay = retry_delay
self._health_cache: Dict[str, Any] = {}
self._health_cache_ttl = 60
self._health_cache_lock = threading.Lock()
if use_cache:
self._init_cache()
def _detect_api_type(self, api_key: str) -> str:
"""
Detect if API key is Free or Pro based on suffix.
Free tier keys end with ':fx', Pro keys do not.
Args:
api_key: DeepL API key
Returns:
"free" or "pro"
"""
if api_key.endswith(DEEPL_FREE_SUFFIX):
return "free"
return "pro"
def _get_api_url(self) -> str:
"""
Get correct API URL based on key type.
Note: deep_translator handles this internally, but we log it.
Returns:
API URL for Free or Pro tier
"""
if self._api_type == "free":
return "https://api-free.deepl.com/v2/translate"
return "https://api.deepl.com/v2/translate"
def _init_cache(self):
"""Initialize or get the translation cache."""
from services.translation_service import _translation_cache
self._cache = _translation_cache
def _normalize_language_code(self, lang_code: str) -> str:
"""
Normalize language code for DeepL.
DeepL uses uppercase language codes (e.g., "EN-US", "FR").
Args:
lang_code: Input language code (e.g., "en", "en-US", "EN-us")
Returns:
Normalized language code for DeepL
"""
if not lang_code or lang_code.lower() == "auto":
return ""
lang_upper = lang_code.upper()
if lang_upper in DEEPL_SUPPORTED_LANGUAGES:
return lang_upper
base_lang = lang_upper.split("-")[0]
if base_lang == "EN":
return "EN-US"
elif base_lang == "PT":
return "PT-BR"
elif base_lang in {
"BG",
"CS",
"DA",
"DE",
"EL",
"ES",
"ET",
"FI",
"FR",
"HU",
"ID",
"IT",
"JA",
"KO",
"LT",
"LV",
"NB",
"NL",
"PL",
"RO",
"RU",
"SK",
"SL",
"SV",
"TR",
"UK",
"ZH",
}:
return base_lang
return lang_upper
def _is_language_supported(self, lang_code: str) -> bool:
"""
Check if a language code is supported by DeepL.
Args:
lang_code: Language code to check
Returns:
True if supported, False otherwise
"""
if not lang_code:
return True
normalized = self._normalize_language_code(lang_code)
return normalized in DEEPL_SUPPORTED_LANGUAGES
def _get_translator(self, source_language: str, target_language: str):
"""Get or create a translator instance for the current thread."""
from deep_translator import DeepLTranslator
source_lang = self._normalize_language_code(source_language)
target_lang = self._normalize_language_code(target_language)
key = f"{source_lang}_{target_lang}"
if not hasattr(self._local, "translators"):
self._local.translators = {}
if key not in self._local.translators:
self._local.translators[key] = DeepLTranslator(
api_key=self._api_key,
source=source_lang if source_lang else "auto",
target=target_lang,
)
return self._local.translators[key]
def _make_api_request(
self, text: str, source_language: str, target_language: str
) -> str:
"""
Make API request with error mapping.
Raises:
DeepLProviderError: For any API errors with specific codes
"""
if len(text.encode("utf-8")) > MAX_TEXT_LENGTH:
raise DeepLProviderError(
code=DEEPL_TEXT_TOO_LONG,
message="Texte trop long (max 128KB par requête).",
details={"text_length": len(text), "max_length": MAX_TEXT_LENGTH},
)
if not self._is_language_supported(target_language):
raise DeepLProviderError(
code=DEEPL_UNSUPPORTED_LANGUAGE,
message=f"Langue '{target_language}' non supportée par DeepL.",
details={"unsupported_language": target_language},
)
try:
translator = self._get_translator(source_language, target_language)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(translator.translate, text)
return future.result(timeout=self.timeout)
except Exception as e:
error_str = str(e).lower()
if (
"quota" in error_str
or "limit" in error_str
or "429" in error_str
or "456" in error_str
):
raise DeepLProviderError(
code=DEEPL_QUOTA_EXCEEDED,
message="Quota DeepL dépassé. Réessayez demain.",
details={"provider": "deepl", "api_type": self._api_type},
)
elif (
"auth" in error_str
or "key" in error_str
or "invalid" in error_str
or "401" in error_str
or "403" in error_str
):
raise DeepLProviderError(
code=DEEPL_INVALID_KEY,
message="Clé API DeepL invalide. Contactez l'administrateur.",
details={"provider": "deepl"},
)
elif "language" in error_str or "not supported" in error_str:
raise DeepLProviderError(
code=DEEPL_UNSUPPORTED_LANGUAGE,
message=f"Langue '{target_language}' non supportée par DeepL.",
details={"unsupported_language": target_language},
)
elif (
isinstance(e, (socket.timeout, TimeoutError, FuturesTimeoutError))
or "timeout" in error_str
):
raise DeepLProviderError(
code=DEEPL_NETWORK_ERROR,
message="Service DeepL indisponible. Réessayez.",
details={"provider": "deepl", "error_type": "timeout"},
)
else:
raise DeepLProviderError(
code=DEEPL_NETWORK_ERROR,
message="Service DeepL indisponible. Réessayez.",
details={"provider": "deepl", "original_error": str(e)[:100]},
)
def get_name(self) -> str:
"""Return provider name."""
return self._provider_name
def is_available(self) -> bool:
"""
Check if DeepL is available (API key configured and API reachable).
Performs a minimal translate call to verify the API is actually reachable.
Uses cached result if available and not expired (TTL 60s).
"""
current_time = time.time()
with self._health_cache_lock:
if "is_available" in self._health_cache:
cached = self._health_cache["is_available"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
available = False
try:
translator = self._get_translator("en", "fr")
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(translator.translate, "a")
future.result(timeout=5)
available = True
except Exception as e:
_log_warning(
"deepl_availability_check_failed",
error=str(e)[:100],
)
with self._health_cache_lock:
self._health_cache["is_available"] = {
"value": available,
"timestamp": current_time,
}
return available
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
"""
Translate a single text string using DeepL.
API Usage Notes:
- DeepL Free tier: 500,000 characters/month
- DeepL Pro: ~€25 per million characters
- 128KB max per request
Optimization: Skips API call if source == target language.
Args:
request: TranslationRequest with text and language info
Returns:
TranslationResponse with translated text
"""
text = request.text
target_language = request.target_language
source_language = request.source_language or "auto"
if not text or not text.strip():
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
)
norm_source = self._normalize_language_code(source_language)
norm_target = self._normalize_language_code(target_language)
if norm_source and norm_source == norm_target:
_log_info(
"deepl_translation_skip",
source_target_lang=target_language,
text_length=len(text),
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
source_language=source_language,
)
if self._use_cache and self._cache:
cached = self._cache.get(
text, target_language, source_language, self._provider_name
)
if cached is not None:
return TranslationResponse(
translated_text=cached,
provider_name=self._provider_name,
from_cache=True,
)
last_error: Optional[DeepLProviderError] = None
retries = 0
while retries <= self.max_retries:
try:
result = self._make_api_request(text, source_language, target_language)
if self._use_cache and self._cache:
self._cache.set(
text,
target_language,
source_language,
self._provider_name,
result,
)
_log_info(
"deepl_translation_success",
chars=len(text),
source_lang=source_language,
target_lang=target_language,
api_type=self._api_type,
retries=retries,
)
return TranslationResponse(
translated_text=result,
provider_name=self._provider_name,
from_cache=False,
)
except DeepLProviderError as e:
last_error = e
if e.code not in _RETRYABLE_ERRORS:
break
retries += 1
if retries <= self.max_retries:
delay = self.retry_delay * (2 ** (retries - 1))
_log_info(
"deepl_translation_retry",
attempt=retries,
delay_s=round(delay, 2),
error_code=e.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
)
time.sleep(delay)
except Exception as e:
last_error = DeepLProviderError(
code=DEEPL_NETWORK_ERROR,
message="Service DeepL indisponible. Réessayez.",
details={"original_error": str(e)[:100]},
)
retries += 1
if retries <= self.max_retries:
delay = self.retry_delay * (2 ** (retries - 1))
time.sleep(delay)
if last_error:
_log_error(
"deepl_translation_failed",
error_code=last_error.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
retries=retries,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error=last_error.message,
error_code=last_error.code,
error_details=last_error.details,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error="Unknown error",
error_code=DEEPL_NETWORK_ERROR,
)
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
"""
Translate multiple texts with optimized batch processing.
Args:
requests: List of TranslationRequest objects
Returns:
List of TranslationResponse objects
"""
if not requests:
return []
return [self.translate_text(req) for req in requests]
def health_check(self) -> ProviderHealthStatus:
"""
Return health status details for the provider.
Performs a lightweight check to verify the provider is operational.
Includes cached result for efficiency.
Returns:
ProviderHealthStatus with availability and latency information
"""
current_time = time.time()
with self._health_cache_lock:
if "health_check" in self._health_cache:
cached = self._health_cache["health_check"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
start_time = time.time()
last_check_iso = datetime.now(timezone.utc).isoformat()
try:
available = self.is_available()
latency_ms = (time.time() - start_time) * 1000
status = ProviderHealthStatus(
name=self._provider_name,
available=available,
latency_ms=round(latency_ms, 2),
error=None if available else "Provider not available",
last_check=last_check_iso,
)
except Exception as e:
latency_ms = (time.time() - start_time) * 1000
status = ProviderHealthStatus(
name=self._provider_name,
available=False,
latency_ms=round(latency_ms, 2),
error=str(e)[:100],
last_check=last_check_iso,
)
with self._health_cache_lock:
self._health_cache["health_check"] = {
"value": status,
"timestamp": current_time,
}
return status
def register_deepl_provider():
"""
Register the DeepL provider in the global registry.
This function should be called during module initialization
to make the provider available through the registry.
"""
from .registry import registry
provider = get_deepl_provider()
if provider:
registry.register("deepl", provider)
return provider
_provider_instance = None
_provider_instance_lock = threading.Lock()
def get_deepl_provider() -> Optional[DeepLTranslationProvider]:
"""Get or create the DeepL provider instance (reads config from env). Thread-safe."""
global _provider_instance
if _provider_instance is None:
with _provider_instance_lock:
if _provider_instance is None:
from .config import ProvidersConfig
if not ProvidersConfig.DEEPL_API_KEY:
return None
_provider_instance = DeepLTranslationProvider(
api_key=ProvidersConfig.DEEPL_API_KEY,
use_cache=True,
timeout=getattr(ProvidersConfig, "DEEPL_TIMEOUT", 30),
max_retries=getattr(ProvidersConfig, "DEEPL_MAX_RETRIES", 3),
retry_delay=getattr(ProvidersConfig, "DEEPL_RETRY_DELAY", 1.0),
)
return _provider_instance
class LegacyDeepLAdapter:
"""
Exposes the new DeepLTranslationProvider via the legacy interface used by
translation_service: .translate(text, target_lang, source_lang) -> str and
.translate_batch(texts, target_lang, source_lang) -> List[str].
Raises TranslationProviderError on failure so the API can return 4xx/502.
"""
def __init__(self):
self._provider = get_deepl_provider()
self.provider_name = "deepl"
def translate(
self, text: str, target_language: str, source_language: str = "auto"
) -> str:
if not self._provider:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
"DEEPL_NOT_CONFIGURED",
"DeepL provider not configured. Set DEEPL_API_KEY.",
None,
)
req = TranslationRequest(
text=text,
target_language=target_language,
source_language=source_language,
)
resp = self._provider.translate_text(req)
if resp.error:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
resp.error_code or "UNKNOWN",
resp.error or "Translation failed",
resp.error_details,
)
return resp.translated_text
def translate_batch(
self,
texts: List[str],
target_language: str,
source_language: str = "auto",
batch_size: int = 50,
) -> List[str]:
if not self._provider:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
"DEEPL_NOT_CONFIGURED",
"DeepL provider not configured. Set DEEPL_API_KEY.",
None,
)
requests = [
TranslationRequest(
text=t,
target_language=target_language,
source_language=source_language,
)
for t in texts
]
responses = self._provider.translate_batch(requests)
result = []
for r in responses:
if r.error:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
r.error_code or "UNKNOWN",
r.error or "Translation failed",
r.error_details,
)
result.append(r.translated_text)
return result
def get_legacy_deepl_adapter() -> LegacyDeepLAdapter:
"""Return an adapter so the legacy translation_service can use the new provider."""
return LegacyDeepLAdapter()

View File

@@ -0,0 +1,345 @@
"""
Fallback Translation Service - Provider fallback chain implementation.
Provides automatic fallback between translation providers when one fails,
ensuring translation remains available even if individual providers are down.
Features:
- Try providers in order until one succeeds
- Return structured error when all providers fail
- Log failed attempts and successful provider
- Never expose HTTP 500 or document content
"""
from typing import List, Optional, Dict, Any
import time
try:
import structlog
logger = structlog.get_logger(__name__)
_HAS_STRUCTLOG = True
except ImportError:
import logging
logger = logging.getLogger(__name__)
_HAS_STRUCTLOG = False
def _log_info(event: str, **kwargs):
"""Log info with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.info(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.info(msg)
def _log_warning(event: str, **kwargs):
"""Log warning with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.warning(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.warning(msg)
def _log_error(event: str, **kwargs):
"""Log error with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.error(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.error(msg)
from .registry import registry
from .schemas import TranslationRequest, TranslationResponse
# Error code for when all providers fail
ALL_PROVIDERS_FAILED = "ALL_PROVIDERS_FAILED"
class AllProvidersFailedError(Exception):
"""
Exception raised when all providers in the fallback chain fail.
This exception is used to signal that no provider could successfully
translate the text, and includes details about which providers were
tried and what errors occurred.
"""
def __init__(
self,
message: str = "Tous les fournisseurs de traduction ont échoué.",
providers_tried: Optional[List[str]] = None,
errors: Optional[List[Dict[str, Any]]] = None,
):
self.code = ALL_PROVIDERS_FAILED
self.message = message
self.providers_tried = providers_tried or []
self.errors = errors or []
super().__init__(message)
def to_dict(self) -> Dict[str, Any]:
"""Convert error to dictionary format for API responses."""
result = {
"error": self.code,
"message": self.message,
"details": {
"providers_tried": self.providers_tried,
"error_count": len(self.errors),
},
}
if self.errors:
# Include last error details (without sensitive info)
last_error = self.errors[-1]
result["details"]["last_error"] = {
"provider": last_error.get("provider"),
"error_code": last_error.get("error_code"),
"message": last_error.get("message", "")[:200], # Truncate
}
return result
def translate_with_fallback(
request: TranslationRequest,
provider_names: List[str],
skip_unavailable: bool = True,
) -> TranslationResponse:
"""
Translate text using a fallback chain of providers.
Iterates through the list of provider names in order, attempting to
translate with each one. Returns the first successful translation.
If all providers fail, raises AllProvidersFailedError.
Args:
request: TranslationRequest with text and language info
provider_names: Ordered list of provider names to try
skip_unavailable: If True, skip providers that are not available
(health check fails). If False, try anyway.
Returns:
TranslationResponse with translated text and provider_name set
to the successful provider.
Raises:
AllProvidersFailedError: When all providers in the chain fail
Example:
>>> request = TranslationRequest(text="Hello", target_language="fr")
>>> response = translate_with_fallback(
... request, ["google", "deepl", "openai"]
... )
>>> print(response.translated_text) # "Bonjour"
>>> print(response.provider_name) # "deepl" (first that succeeded)
"""
if not provider_names:
raise AllProvidersFailedError(
message="Aucun fournisseur configuré dans la chaîne de fallback.",
providers_tried=[],
)
providers_tried: List[str] = []
errors: List[Dict[str, Any]] = []
_log_info(
"fallback_translation_started",
providers=provider_names,
source_lang=request.source_language,
target_lang=request.target_language,
text_length=len(request.text),
)
for provider_name in provider_names:
# Get provider from registry
provider = registry.get(provider_name)
if provider is None:
_log_warning(
"fallback_provider_not_registered",
provider=provider_name,
)
errors.append(
{
"provider": provider_name,
"error_code": "PROVIDER_NOT_REGISTERED",
"message": f"Provider '{provider_name}' not registered",
}
)
providers_tried.append(provider_name)
continue
# Check availability if requested
if skip_unavailable and not provider.is_available():
_log_info(
"fallback_provider_unavailable",
provider=provider_name,
)
errors.append(
{
"provider": provider_name,
"error_code": "PROVIDER_UNAVAILABLE",
"message": f"Provider '{provider_name}' is not available",
}
)
providers_tried.append(provider_name)
continue
# Try to translate
start_time = time.time()
try:
response = provider.translate_text(request)
latency_ms = (time.time() - start_time) * 1000
# Check if translation succeeded
if response.error is None:
# Success!
_log_info(
"fallback_translation_success",
provider=provider_name,
latency_ms=round(latency_ms, 2),
attempts=len(providers_tried) + 1,
text_length=len(request.text),
source_lang=request.source_language,
target_lang=request.target_language,
)
# Ensure provider_name is set
if not response.provider_name:
response.provider_name = provider_name
return response
else:
# Provider returned an error
_log_warning(
"fallback_provider_error",
provider=provider_name,
error_code=response.error_code,
error_message=response.error[:200], # Truncate
)
errors.append(
{
"provider": provider_name,
"error_code": response.error_code,
"message": response.error,
}
)
providers_tried.append(provider_name)
except Exception as e:
# Provider raised an exception
latency_ms = (time.time() - start_time) * 1000
error_str = str(e)
_log_error(
"fallback_provider_exception",
provider=provider_name,
error_type=type(e).__name__,
latency_ms=round(latency_ms, 2),
)
errors.append(
{
"provider": provider_name,
"error_code": "PROVIDER_EXCEPTION",
"message": error_str[:200], # Truncate
}
)
providers_tried.append(provider_name)
# All providers failed
_log_error(
"fallback_all_providers_failed",
providers_tried=providers_tried,
error_count=len(errors),
text_length=len(request.text),
source_lang=request.source_language,
target_lang=request.target_language,
)
raise AllProvidersFailedError(
message="Tous les fournisseurs de traduction ont échoué. Veuillez réessayer plus tard.",
providers_tried=providers_tried,
errors=errors,
)
def translate_with_fallback_by_mode(
request: TranslationRequest,
mode: str = "auto",
) -> TranslationResponse:
"""
Translate text using the fallback chain for a specific mode.
Args:
request: TranslationRequest with text and language info
mode: "classic" for Classic providers, "llm" for LLM providers,
"auto" for general fallback chain
Returns:
TranslationResponse with translated text
Raises:
AllProvidersFailedError: When all providers fail
"""
from .config import ProvidersConfig
provider_names = ProvidersConfig.get_fallback_chain(mode)
if not provider_names:
raise AllProvidersFailedError(
message=f"Aucune chaîne de fallback configurée pour le mode '{mode}'.",
providers_tried=[],
)
return translate_with_fallback(request, provider_names)
class LegacyFallbackAdapter:
"""
Exposes the fallback chain via the legacy interface used by translation_service:
.translate(text, target_lang, source_lang) -> str and
.translate_batch(texts, target_lang, source_lang) -> List[str].
Raises AllProvidersFailedError when all providers fail (API returns 502).
"""
def __init__(self, mode: str = "classic"):
"""
Args:
mode: "classic" (Google → DeepL) or "llm" (Ollama → OpenAI)
"""
self._mode = mode.lower()
self.provider_name = f"fallback_{self._mode}"
self._last_provider_used: Optional[str] = None
def translate(
self, text: str, target_language: str, source_language: str = "auto"
) -> str:
req = TranslationRequest(
text=text,
target_language=target_language,
source_language=source_language,
)
response = translate_with_fallback_by_mode(req, self._mode)
self._last_provider_used = response.provider_name or self._last_provider_used
return response.translated_text
def translate_batch(
self,
texts: List[str],
target_language: str,
source_language: str = "auto",
batch_size: int = 50,
) -> List[str]:
results: List[str] = []
for t in texts:
req = TranslationRequest(
text=t,
target_language=target_language,
source_language=source_language,
)
response = translate_with_fallback_by_mode(req, self._mode)
self._last_provider_used = response.provider_name or self._last_provider_used
results.append(response.translated_text)
return results

View File

@@ -0,0 +1,534 @@
"""
Google Translate Provider - Production-ready implementation.
Extends TranslationProvider base class with robust error handling,
retry logic, and health monitoring.
Features:
- Specific error codes for all Google API errors
- Retry logic with exponential backoff for transient errors
- Timeout configuration
- Health check with caching
- Structlog-compatible logging (no document content in logs)
"""
import os
import socket
import threading
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
try:
import structlog
logger = structlog.get_logger(__name__)
except ImportError:
import logging
logger = logging.getLogger(__name__)
from .base import TranslationProvider
from .schemas import (
BatchTranslationRequest,
BatchTranslationResponse,
ProviderHealthStatus,
TranslationRequest,
TranslationResponse,
)
GOOGLE_QUOTA_EXCEEDED = "GOOGLE_QUOTA_EXCEEDED"
GOOGLE_INVALID_KEY = "GOOGLE_INVALID_KEY"
GOOGLE_NETWORK_ERROR = "GOOGLE_NETWORK_ERROR"
GOOGLE_UNSUPPORTED_LANGUAGE = "GOOGLE_UNSUPPORTED_LANGUAGE"
GOOGLE_TEXT_TOO_LONG = "GOOGLE_TEXT_TOO_LONG"
_RETRYABLE_ERRORS = {GOOGLE_NETWORK_ERROR, GOOGLE_QUOTA_EXCEEDED}
class GoogleProviderError(Exception):
"""Exception raised for Google Translate API errors."""
def __init__(
self, code: str, message: str, details: Optional[Dict[str, Any]] = None
):
self.code = code
self.message = message
self.details = details or {}
super().__init__(message)
def to_dict(self) -> Dict[str, Any]:
"""Convert error to dictionary format."""
result = {
"error": self.code,
"message": self.message,
}
if self.details:
result["details"] = self.details
return result
class GoogleTranslationProvider(TranslationProvider):
"""
Google Translate implementation using deep_translator library.
Features:
- Thread-safe translator instances per thread
- Caching support (uses global cache from translation_service)
- Batch translation with optimized processing
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Configurable timeout
- Health check with result caching
"""
def __init__(
self,
use_cache: bool = True,
timeout: int = 30,
max_retries: int = 3,
retry_delay: float = 1.0,
):
"""
Initialize Google Translate provider.
Args:
use_cache: Whether to use translation caching (default: True)
timeout: Request timeout in seconds (default: 30)
max_retries: Maximum retry attempts for transient errors (default: 3)
retry_delay: Initial retry delay in seconds (default: 1.0)
"""
self._local = threading.local()
self._use_cache = use_cache
self._provider_name = "google"
self._cache = None
self.timeout = timeout
self.max_retries = max_retries
self.retry_delay = retry_delay
self._health_cache: Dict[str, Any] = {}
self._health_cache_ttl = 60
self._health_cache_lock = threading.Lock()
if use_cache:
self._init_cache()
def _init_cache(self):
"""Initialize or get the translation cache."""
from services.translation_service import _translation_cache
self._cache = _translation_cache
def _get_translator(self, source_language: str, target_language: str):
"""Get or create a translator instance for the current thread."""
from deep_translator import GoogleTranslator
key = f"{source_language}_{target_language}"
if not hasattr(self._local, "translators"):
self._local.translators = {}
if key not in self._local.translators:
self._local.translators[key] = GoogleTranslator(
source=source_language, target=target_language
)
return self._local.translators[key]
def _make_api_request(
self, text: str, source_language: str, target_language: str
) -> str:
"""
Make API request with error mapping.
Raises:
GoogleProviderError: For any API errors with specific codes
"""
if len(text) > 5000:
raise GoogleProviderError(
code=GOOGLE_TEXT_TOO_LONG,
message="Texte trop long (max 5000 caractères par requête).",
details={"text_length": len(text), "max_length": 5000},
)
try:
translator = self._get_translator(source_language, target_language)
# Apply timeout via executor (deep_translator has no timeout parameter)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(translator.translate, text)
return future.result(timeout=self.timeout)
except Exception as e:
error_str = str(e).lower()
if "quota" in error_str or "limit" in error_str or "429" in error_str:
raise GoogleProviderError(
code=GOOGLE_QUOTA_EXCEEDED,
message="Quota Google Translate dépassé. Réessayez demain.",
details={"provider": "google"},
)
elif "api" in error_str and (
"key" in error_str
or "invalid" in error_str
or "401" in error_str
or "403" in error_str
):
raise GoogleProviderError(
code=GOOGLE_INVALID_KEY,
message="Clé API Google invalide. Contactez l'administrateur.",
details={"provider": "google"},
)
elif "language" in error_str or "not supported" in error_str:
raise GoogleProviderError(
code=GOOGLE_UNSUPPORTED_LANGUAGE,
message=f"Langue '{target_language}' non supportée par Google.",
details={"unsupported_language": target_language},
)
elif (
isinstance(e, (socket.timeout, TimeoutError, FuturesTimeoutError))
or "timeout" in error_str
):
raise GoogleProviderError(
code=GOOGLE_NETWORK_ERROR,
message="Service Google Translate indisponible. Réessayez.",
details={"provider": "google", "error_type": "timeout"},
)
else:
raise GoogleProviderError(
code=GOOGLE_NETWORK_ERROR,
message="Service Google Translate indisponible. Réessayez.",
details={"provider": "google", "original_error": str(e)[:100]},
)
def get_name(self) -> str:
"""Return provider name."""
return self._provider_name
def is_available(self) -> bool:
"""
Check if Google Translate is available.
Uses cached result if available and not expired.
"""
current_time = time.time()
with self._health_cache_lock:
if "is_available" in self._health_cache:
cached = self._health_cache["is_available"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
try:
translator = self._get_translator("auto", "en")
available = translator is not None
except Exception as e:
logger.warning(
"google_availability_check_failed",
error=str(e)[:100],
)
available = False
with self._health_cache_lock:
self._health_cache["is_available"] = {
"value": available,
"timestamp": current_time,
}
return available
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
"""
Translate a single text string using Google Translate.
API Usage Notes:
- Google Translate free tier: 500,000 characters/month
- 5,000 characters max per request
- Cost: ~$20 per million characters (paid tier)
Optimization: Skips API call if source == target language.
Args:
request: TranslationRequest with text and language info
Returns:
TranslationResponse with translated text
"""
text = request.text
target_language = request.target_language
source_language = request.source_language or "auto"
if not text or not text.strip():
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
)
# Optimization: Skip if source and target are the same
if source_language != "auto" and source_language == target_language:
logger.info(
"google_translation_skip",
source_target_lang=target_language,
text_length=len(text),
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
source_language=source_language,
)
if self._use_cache and self._cache:
cached = self._cache.get(
text, target_language, source_language, self._provider_name
)
if cached is not None:
return TranslationResponse(
translated_text=cached,
provider_name=self._provider_name,
from_cache=True,
)
last_error: Optional[GoogleProviderError] = None
retries = 0
while retries <= self.max_retries:
try:
result = self._make_api_request(text, source_language, target_language)
if self._use_cache and self._cache:
self._cache.set(
text,
target_language,
source_language,
self._provider_name,
result,
)
# Log usage metrics (character count, API call)
logger.info(
"google_translation_success",
chars=len(text),
source_lang=source_language,
target_lang=target_language,
retries=retries,
)
return TranslationResponse(
translated_text=result,
provider_name=self._provider_name,
from_cache=False,
)
except GoogleProviderError as e:
last_error = e
if e.code not in _RETRYABLE_ERRORS:
break
retries += 1
if retries <= self.max_retries:
delay = self.retry_delay * (2 ** (retries - 1))
logger.info(
"google_translation_retry",
attempt=retries,
delay_s=round(delay, 2),
error_code=e.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
)
time.sleep(delay)
except Exception as e:
last_error = GoogleProviderError(
code=GOOGLE_NETWORK_ERROR,
message="Service Google Translate indisponible. Réessayez.",
details={"original_error": str(e)[:100]},
)
retries += 1
if retries <= self.max_retries:
delay = self.retry_delay * (2 ** (retries - 1))
time.sleep(delay)
if last_error:
logger.error(
"google_translation_failed",
error_code=last_error.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
retries=retries,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error=last_error.message,
error_code=last_error.code,
error_details=last_error.details,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error="Unknown error",
error_code=GOOGLE_NETWORK_ERROR,
)
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
"""
Translate multiple texts with optimized batch processing.
Args:
requests: List of TranslationRequest objects
Returns:
List of TranslationResponse objects
"""
if not requests:
return []
return [self.translate_text(req) for req in requests]
def health_check(self) -> ProviderHealthStatus:
"""
Return health status details for the provider.
Performs a lightweight check to verify the provider is operational.
Includes cached result for efficiency.
Returns:
ProviderHealthStatus with availability and latency information
"""
current_time = time.time()
with self._health_cache_lock:
if "health_check" in self._health_cache:
cached = self._health_cache["health_check"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
start_time = time.time()
last_check_iso = datetime.now(timezone.utc).isoformat()
try:
available = self.is_available()
latency_ms = (time.time() - start_time) * 1000
status = ProviderHealthStatus(
name=self._provider_name,
available=available,
latency_ms=round(latency_ms, 2),
error=None if available else "Provider not available",
last_check=last_check_iso,
)
except Exception as e:
latency_ms = (time.time() - start_time) * 1000
status = ProviderHealthStatus(
name=self._provider_name,
available=False,
latency_ms=round(latency_ms, 2),
error=str(e)[:100],
last_check=last_check_iso,
)
with self._health_cache_lock:
self._health_cache["health_check"] = {
"value": status,
"timestamp": current_time,
}
return status
def register_google_provider():
"""
Register the Google provider in the global registry.
This function should be called during module initialization
to make the provider available through the registry.
"""
from .registry import registry
provider = get_google_provider()
registry.register("google", provider)
return provider
_provider_instance = None
def get_google_provider() -> GoogleTranslationProvider:
"""Get or create the Google provider instance (reads config from env)."""
global _provider_instance
if _provider_instance is None:
from .config import ProvidersConfig
_provider_instance = GoogleTranslationProvider(
use_cache=True,
timeout=ProvidersConfig.GOOGLE_TRANSLATE_TIMEOUT,
max_retries=ProvidersConfig.GOOGLE_TRANSLATE_MAX_RETRIES,
retry_delay=ProvidersConfig.GOOGLE_TRANSLATE_RETRY_DELAY,
)
return _provider_instance
class LegacyGoogleAdapter:
"""
Exposes the new GoogleTranslationProvider via the legacy interface used by
translation_service: .translate(text, target_lang, source_lang) -> str and
.translate_batch(texts, target_lang, source_lang) -> List[str].
Raises TranslationProviderError on failure so the API can return 4xx/502.
"""
def __init__(self):
self._provider = get_google_provider()
self.provider_name = "google"
def translate(
self, text: str, target_language: str, source_language: str = "auto"
) -> str:
req = TranslationRequest(
text=text,
target_language=target_language,
source_language=source_language,
)
resp = self._provider.translate_text(req)
if resp.error:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
resp.error_code or "UNKNOWN",
resp.error or "Translation failed",
resp.error_details,
)
return resp.translated_text
def translate_batch(
self,
texts: List[str],
target_language: str,
source_language: str = "auto",
batch_size: int = 50,
) -> List[str]:
requests = [
TranslationRequest(
text=t,
target_language=target_language,
source_language=source_language,
)
for t in texts
]
responses = self._provider.translate_batch(requests)
result = []
for r in responses:
if r.error:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
r.error_code or "UNKNOWN",
r.error or "Translation failed",
r.error_details,
)
result.append(r.translated_text)
return result
def get_legacy_google_adapter() -> LegacyGoogleAdapter:
"""Return an adapter so the legacy translation_service can use the new provider."""
return LegacyGoogleAdapter()

View File

@@ -0,0 +1,605 @@
"""
Ollama Provider - Local LLM translation provider.
Extends TranslationProvider base class with robust error handling,
retry logic, and health monitoring for local Ollama instances.
Features:
- Local LLM translation via Ollama REST API
- Custom system prompt support
- Specific error codes for all Ollama API errors
- Retry logic with exponential backoff for transient errors
- Timeout configuration (longer for LLM)
- Health check with caching
- Structlog-compatible logging (no document content in logs)
"""
import socket
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin
try:
import structlog
logger = structlog.get_logger(__name__)
_HAS_STRUCTLOG = True
except ImportError:
import logging
logger = logging.getLogger(__name__)
_HAS_STRUCTLOG = False
def _log_info(event: str, **kwargs):
"""Log info with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.info(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.info(msg)
def _log_warning(event: str, **kwargs):
"""Log warning with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.warning(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.warning(msg)
def _log_error(event: str, **kwargs):
"""Log error with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.error(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.error(msg)
import requests
from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError
from .base import TranslationProvider
from .schemas import (
ProviderHealthStatus,
TranslationRequest,
TranslationResponse,
)
OLLAMA_UNAVAILABLE = "OLLAMA_UNAVAILABLE"
OLLAMA_MODEL_NOT_FOUND = "OLLAMA_MODEL_NOT_FOUND"
OLLAMA_TIMEOUT = "OLLAMA_TIMEOUT"
OLLAMA_GENERATION_ERROR = "OLLAMA_GENERATION_ERROR"
OLLAMA_CONTEXT_TOO_LONG = "OLLAMA_CONTEXT_TOO_LONG"
_RETRYABLE_ERRORS = {OLLAMA_UNAVAILABLE, OLLAMA_TIMEOUT}
class OllamaProviderError(Exception):
"""Exception raised for Ollama API errors."""
def __init__(
self, code: str, message: str, details: Optional[Dict[str, Any]] = None
):
self.code = code
self.message = message
self.details = details or {}
super().__init__(message)
def to_dict(self) -> Dict[str, Any]:
"""Convert error to dictionary format."""
result = {
"error": self.code,
"message": self.message,
}
if self.details:
result["details"] = self.details
return result
DEFAULT_TRANSLATION_PROMPT = """You are a professional translator. Translate the following text from {source_lang} to {target_lang}.
Rules:
- Translate ONLY the text, do not add explanations or notes
- Preserve the original formatting, line breaks, and structure
- Maintain the original tone and style
- For technical terms, use the standard translation in the target language
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""
def _build_system_prompt(
source_lang: str, target_lang: str, custom_prompt: Optional[str] = None
) -> str:
"""Build system prompt for translation."""
if custom_prompt:
return custom_prompt
return DEFAULT_TRANSLATION_PROMPT.format(
source_lang=source_lang, target_lang=target_lang
)
def _get_language_name(code: str) -> str:
"""Convert language code to full name for better LLM understanding."""
language_names = {
"en": "English",
"fr": "French",
"es": "Spanish",
"de": "German",
"it": "Italian",
"pt": "Portuguese",
"nl": "Dutch",
"ru": "Russian",
"zh": "Chinese",
"ja": "Japanese",
"ko": "Korean",
"ar": "Arabic",
"hi": "Hindi",
"tr": "Turkish",
"pl": "Polish",
"vi": "Vietnamese",
"th": "Thai",
"id": "Indonesian",
"ms": "Malay",
"uk": "Ukrainian",
"cs": "Czech",
"sv": "Swedish",
"da": "Danish",
"fi": "Finnish",
"no": "Norwegian",
"el": "Greek",
"he": "Hebrew",
"ro": "Romanian",
"hu": "Hungarian",
"bg": "Bulgarian",
"sk": "Slovak",
"hr": "Croatian",
"sl": "Slovenian",
"lt": "Lithuanian",
"lv": "Latvian",
"et": "Estonian",
}
base_code = code.split("-")[0].lower()
return language_names.get(base_code, code)
class OllamaTranslationProvider(TranslationProvider):
"""
Ollama LLM implementation for local translation.
Features:
- Uses Ollama REST API (/api/chat endpoint)
- Custom system prompt support for translation context
- Thread-safe HTTP client
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Configurable timeout (default 120s for LLM)
- Health check with result caching
"""
def __init__(
self,
base_url: str = "http://localhost:11434",
model: Optional[str] = None,
timeout: int = 120,
max_retries: int = 2,
retry_delay: float = 2.0,
):
"""
Initialize Ollama provider.
Args:
base_url: Ollama API base URL (default: http://localhost:11434)
model: Model name (e.g. llama3, mistral). If None, uses OLLAMA_MODEL from config.
timeout: Request timeout in seconds (default: 120 for LLM)
max_retries: Maximum retry attempts for transient errors (default: 2)
retry_delay: Initial retry delay in seconds (default: 2.0)
"""
if model is None:
from .config import ProvidersConfig
model = ProvidersConfig.OLLAMA_MODEL
self._base_url = base_url.rstrip("/")
self._model = model
self._provider_name = "ollama"
self.timeout = timeout
self.max_retries = max_retries
self.retry_delay = retry_delay
self._health_cache: Dict[str, Any] = {}
self._health_cache_ttl = 60
self._health_cache_lock = threading.Lock()
self._available_models: Optional[List[str]] = None
self._models_cache_time: float = 0
self._models_cache_ttl = 300
def _fetch_available_models(self) -> List[str]:
"""Fetch list of available (pulled) models from Ollama."""
current_time = time.time()
if (
self._available_models is not None
and current_time - self._models_cache_time < self._models_cache_ttl
):
return self._available_models
try:
response = requests.get(f"{self._base_url}/api/tags", timeout=10)
if response.status_code == 200:
data = response.json()
models = [m.get("name", "") for m in data.get("models", [])]
self._available_models = models
self._models_cache_time = current_time
return models
except Exception as e:
_log_warning("ollama_models_fetch_failed", error=str(e)[:100])
return []
def _check_model_available(self, model: str) -> bool:
"""Check if a specific model is available (pulled)."""
models = self._fetch_available_models()
return any(m.startswith(model) or model in m for m in models)
def _make_api_request(self, text: str, system_prompt: str) -> str:
"""
Make API request to Ollama.
Raises:
OllamaProviderError: For any API errors with specific codes
"""
if not text or not text.strip():
return text
if len(text) > 128000:
raise OllamaProviderError(
code=OLLAMA_CONTEXT_TOO_LONG,
message="Texte trop long pour le modèle (max ~128K caractères).",
details={"text_length": len(text), "max_chars": 128000},
)
if not self._check_model_available(self._model):
raise OllamaProviderError(
code=OLLAMA_MODEL_NOT_FOUND,
message=f"Modèle '{self._model}' non trouvé. Exécutez: ollama pull {self._model}",
details={"model": self._model, "provider": "ollama"},
)
payload = {
"model": self._model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text},
],
"stream": False,
"options": {"temperature": 0.3},
}
try:
response = requests.post(
f"{self._base_url}/api/chat",
json=payload,
timeout=self.timeout,
)
if response.status_code == 404:
raise OllamaProviderError(
code=OLLAMA_MODEL_NOT_FOUND,
message=f"Modèle '{self._model}' non trouvé. Exécutez: ollama pull {self._model}",
details={"model": self._model, "status_code": 404},
)
if response.status_code != 200:
error_text = response.text[:200] if response.text else "Unknown error"
raise OllamaProviderError(
code=OLLAMA_GENERATION_ERROR,
message=f"Erreur de génération Ollama: {error_text}",
details={"status_code": response.status_code, "model": self._model},
)
data = response.json()
message = data.get("message", {})
content = message.get("content", "")
if not content:
raise OllamaProviderError(
code=OLLAMA_GENERATION_ERROR,
message="Erreur de génération Ollama: réponse vide",
details={"model": self._model, "response": str(data)[:200]},
)
return content.strip()
except Timeout:
raise OllamaProviderError(
code=OLLAMA_TIMEOUT,
message="Délai d'attente Ollama dépassé. Réessayez avec un texte plus court.",
details={"provider": "ollama", "timeout_seconds": self.timeout},
)
except RequestsConnectionError:
raise OllamaProviderError(
code=OLLAMA_UNAVAILABLE,
message="Service Ollama indisponible. Vérifiez que Ollama est en cours d'exécution.",
details={"provider": "ollama", "base_url": self._base_url},
)
except OllamaProviderError:
raise
except Exception as e:
error_str = str(e).lower()
if "connection" in error_str or "refused" in error_str:
raise OllamaProviderError(
code=OLLAMA_UNAVAILABLE,
message="Service Ollama indisponible. Vérifiez que Ollama est en cours d'exécution.",
details={"provider": "ollama", "base_url": self._base_url},
)
raise OllamaProviderError(
code=OLLAMA_GENERATION_ERROR,
message=f"Erreur de génération Ollama: {str(e)[:100]}",
details={"provider": "ollama", "original_error": str(e)[:100]},
)
def get_name(self) -> str:
"""Return provider name."""
return self._provider_name
def is_available(self) -> bool:
"""
Check if Ollama is available.
Uses cached result if available and not expired.
"""
current_time = time.time()
with self._health_cache_lock:
if "is_available" in self._health_cache:
cached = self._health_cache["is_available"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
try:
response = requests.get(f"{self._base_url}/api/tags", timeout=5)
available = response.status_code == 200
except Exception as e:
_log_warning("ollama_availability_check_failed", error=str(e)[:100])
available = False
with self._health_cache_lock:
self._health_cache["is_available"] = {
"value": available,
"timestamp": current_time,
}
return available
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
"""
Translate a single text string using Ollama LLM.
Supports custom system prompt via request.metadata["custom_prompt"].
Args:
request: TranslationRequest with text and language info
Returns:
TranslationResponse with translated text
"""
text = request.text
target_language = request.target_language
source_language = request.source_language or "auto"
if not text or not text.strip():
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
)
source_lang_name = _get_language_name(source_language)
target_lang_name = _get_language_name(target_language)
custom_prompt = None
if request.metadata:
custom_prompt = request.metadata.get("custom_prompt")
system_prompt = _build_system_prompt(
source_lang_name, target_lang_name, custom_prompt
)
last_error: Optional[OllamaProviderError] = None
retries = 0
while retries <= self.max_retries:
try:
start_time = time.time()
result = self._make_api_request(text, system_prompt)
latency = time.time() - start_time
_log_info(
"ollama_translation_success",
chars=len(text),
source_lang=source_language,
target_lang=target_language,
model=self._model,
latency_ms=round(latency * 1000, 2),
retries=retries,
)
return TranslationResponse(
translated_text=result,
provider_name=self._provider_name,
from_cache=False,
source_language=source_language,
)
except OllamaProviderError as e:
last_error = e
if e.code not in _RETRYABLE_ERRORS:
break
retries += 1
if retries <= self.max_retries:
delay = self.retry_delay * (2 ** (retries - 1))
_log_info(
"ollama_translation_retry",
attempt=retries,
delay_s=round(delay, 2),
error_code=e.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
)
time.sleep(delay)
except Exception as e:
last_error = OllamaProviderError(
code=OLLAMA_GENERATION_ERROR,
message=f"Erreur de génération Ollama: {str(e)[:100]}",
details={"original_error": str(e)[:100]},
)
retries += 1
if retries <= self.max_retries:
delay = self.retry_delay * (2 ** (retries - 1))
time.sleep(delay)
if last_error:
_log_error(
"ollama_translation_failed",
error_code=last_error.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
retries=retries,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error=last_error.message,
error_code=last_error.code,
error_details=last_error.details,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error="Unknown error",
error_code=OLLAMA_GENERATION_ERROR,
)
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
"""
Translate multiple texts.
Args:
requests: List of TranslationRequest objects
Returns:
List of TranslationResponse objects
"""
if not requests:
return []
return [self.translate_text(req) for req in requests]
def health_check(self) -> ProviderHealthStatus:
"""
Return health status details for the provider.
Includes cached result for efficiency.
Returns:
ProviderHealthStatus with availability and latency information
"""
current_time = time.time()
with self._health_cache_lock:
if "health_check" in self._health_cache:
cached = self._health_cache["health_check"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
start_time = time.time()
last_check_iso = datetime.now(timezone.utc).isoformat()
try:
models = self._fetch_available_models()
model_available = self._check_model_available(self._model)
available = len(models) > 0 and model_available
latency_ms = (time.time() - start_time) * 1000
error_msg = None
if not available and len(models) == 0:
error_msg = "Service Ollama indisponible. Vérifiez que Ollama est en cours d'exécution."
elif not model_available:
error_msg = f"Modèle '{self._model}' non trouvé. Exécutez: ollama pull {self._model}"
status = ProviderHealthStatus(
name=self._provider_name,
available=available,
latency_ms=round(latency_ms, 2),
error=error_msg,
last_check=last_check_iso,
model=self._model,
model_available=model_available,
)
except Exception as e:
latency_ms = (time.time() - start_time) * 1000
status = ProviderHealthStatus(
name=self._provider_name,
available=False,
latency_ms=round(latency_ms, 2),
error=str(e)[:100],
last_check=last_check_iso,
model=self._model,
model_available=None,
)
with self._health_cache_lock:
self._health_cache["health_check"] = {
"value": status,
"timestamp": current_time,
}
return status
def register_ollama_provider():
"""
Register the Ollama provider in the global registry.
This function should be called during module initialization
to make the provider available through the registry.
"""
from .registry import registry
provider = get_ollama_provider()
registry.register("ollama", provider)
return provider
_provider_instance: Optional[OllamaTranslationProvider] = None
_provider_lock = threading.Lock()
def get_ollama_provider() -> OllamaTranslationProvider:
"""Get or create the Ollama provider instance (reads config from env)."""
global _provider_instance
if _provider_instance is None:
with _provider_lock:
if _provider_instance is None:
from .config import ProvidersConfig
_provider_instance = OllamaTranslationProvider(
base_url=ProvidersConfig.OLLAMA_BASE_URL,
model=ProvidersConfig.OLLAMA_MODEL,
timeout=ProvidersConfig.OLLAMA_TIMEOUT,
max_retries=ProvidersConfig.OLLAMA_MAX_RETRIES,
retry_delay=ProvidersConfig.OLLAMA_RETRY_DELAY,
)
return _provider_instance

View File

@@ -0,0 +1,670 @@
"""
OpenAI Provider - Cloud LLM translation provider.
Extends TranslationProvider base class with robust error handling,
retry logic, and health monitoring for OpenAI API.
Features:
- Cloud LLM translation via OpenAI Chat Completions API
- Custom system prompt support
- Specific error codes for all OpenAI API errors
- Retry logic with exponential backoff for transient errors
- Timeout configuration (faster than local Ollama)
- Health check with caching
- Structlog-compatible logging (no document content in logs)
"""
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
try:
import structlog
logger = structlog.get_logger(__name__)
_HAS_STRUCTLOG = True
except ImportError:
import logging
logger = logging.getLogger(__name__)
_HAS_STRUCTLOG = False
def _log_info(event: str, **kwargs):
"""Log info with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.info(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.info(msg)
def _log_warning(event: str, **kwargs):
"""Log warning with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.warning(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.warning(msg)
def _log_error(event: str, **kwargs):
"""Log error with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.error(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.error(msg)
import requests
from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError
from .base import TranslationProvider
from .schemas import (
ProviderHealthStatus,
TranslationRequest,
TranslationResponse,
)
# Error codes
OPENAI_RATE_LIMITED = "OPENAI_RATE_LIMITED"
OPENAI_INVALID_KEY = "OPENAI_INVALID_KEY"
OPENAI_QUOTA_EXCEEDED = "OPENAI_QUOTA_EXCEEDED"
OPENAI_TIMEOUT = "OPENAI_TIMEOUT"
OPENAI_SERVICE_ERROR = "OPENAI_SERVICE_ERROR"
OPENAI_CONTEXT_TOO_LONG = "OPENAI_CONTEXT_TOO_LONG"
_RETRYABLE_ERRORS = {OPENAI_RATE_LIMITED, OPENAI_TIMEOUT, OPENAI_SERVICE_ERROR}
class OpenAIProviderError(Exception):
"""Exception raised for OpenAI API errors."""
def __init__(
self, code: str, message: str, details: Optional[Dict[str, Any]] = None
):
self.code = code
self.message = message
self.details = details or {}
super().__init__(message)
def to_dict(self) -> Dict[str, Any]:
"""Convert error to dictionary format."""
result = {
"error": self.code,
"message": self.message,
}
if self.details:
result["details"] = self.details
return result
DEFAULT_TRANSLATION_PROMPT = """You are a professional translator. Translate the following text from {source_lang} to {target_lang}.
Rules:
- Translate ONLY the text, do not add explanations or notes
- Preserve the original formatting, line breaks, and structure
- Maintain the original tone and style
- For technical terms, use the standard translation in the target language
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""
def _build_system_prompt(
source_lang: str, target_lang: str, custom_prompt: Optional[str] = None
) -> str:
"""Build system prompt for translation."""
if custom_prompt:
return custom_prompt
return DEFAULT_TRANSLATION_PROMPT.format(
source_lang=source_lang, target_lang=target_lang
)
def _get_language_name(code: str) -> str:
"""Convert language code to full name for better LLM understanding."""
language_names = {
"en": "English",
"fr": "French",
"es": "Spanish",
"de": "German",
"it": "Italian",
"pt": "Portuguese",
"nl": "Dutch",
"ru": "Russian",
"zh": "Chinese",
"ja": "Japanese",
"ko": "Korean",
"ar": "Arabic",
"hi": "Hindi",
"tr": "Turkish",
"pl": "Polish",
"vi": "Vietnamese",
"th": "Thai",
"id": "Indonesian",
"ms": "Malay",
"uk": "Ukrainian",
"cs": "Czech",
"sv": "Swedish",
"da": "Danish",
"fi": "Finnish",
"no": "Norwegian",
"el": "Greek",
"he": "Hebrew",
"ro": "Romanian",
"hu": "Hungarian",
"bg": "Bulgarian",
"sk": "Slovak",
"hr": "Croatian",
"sl": "Slovenian",
"lt": "Lithuanian",
"lv": "Latvian",
"et": "Estonian",
}
base_code = code.split("-")[0].lower()
return language_names.get(base_code, code)
class OpenAITranslationProvider(TranslationProvider):
"""
OpenAI LLM implementation for cloud translation.
Features:
- Uses OpenAI Chat Completions API
- Custom system prompt support for translation context
- Thread-safe HTTP client
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Configurable timeout (default 60s for cloud API)
- Health check with result caching
"""
def __init__(
self,
api_key: str,
model: str = "gpt-4o-mini",
timeout: int = 60,
max_retries: int = 3,
retry_delay: float = 1.0,
base_url: str = "https://api.openai.com/v1",
health_check_timeout: int = 5,
):
"""
Initialize OpenAI provider.
Args:
api_key: OpenAI API key
model: Model name to use (default: gpt-4o-mini)
timeout: Request timeout in seconds (default: 60)
max_retries: Maximum retry attempts for transient errors (default: 3)
retry_delay: Initial retry delay in seconds (default: 1.0)
base_url: OpenAI API base URL (default: https://api.openai.com/v1)
health_check_timeout: Timeout for health check requests in seconds (default: 5)
"""
if not api_key or not api_key.strip():
raise ValueError("OpenAI API key cannot be empty")
self._api_key = api_key
self._model = model
self._base_url = base_url.rstrip("/")
self._provider_name = "openai"
self._timeout = timeout
self._max_retries = max_retries
self._retry_delay = retry_delay
self._health_check_timeout = health_check_timeout
self._health_cache: Dict[str, Any] = {}
self._health_cache_ttl = 60
self._health_cache_lock = threading.Lock()
def _make_api_request(self, text: str, system_prompt: str) -> tuple:
"""
Make API request to OpenAI.
Returns:
Tuple of (translated_content, usage_dict). usage_dict may be empty.
Raises:
OpenAIProviderError: For any API errors with specific codes
"""
if not text or not text.strip():
return text, {}
# Check text length (rough estimate: 1 token ~= 4 chars)
if len(text) > 16000: # ~4000 tokens
raise OpenAIProviderError(
code=OPENAI_CONTEXT_TOO_LONG,
message="Texte trop long pour le modèle (max ~4000 tokens).",
details={"text_length": len(text), "max_tokens": 4000},
)
url = f"{self._base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self._model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text},
],
"temperature": 0.3,
"max_tokens": 4096,
}
try:
response = requests.post(
url,
headers=headers,
json=payload,
timeout=self._timeout,
)
# Handle specific HTTP status codes
if response.status_code == 401:
raise OpenAIProviderError(
code=OPENAI_INVALID_KEY,
message="Clé API OpenAI invalide. Vérifiez votre configuration.",
details={"status_code": 401},
)
if response.status_code == 429:
try:
error_data = response.json().get("error", {}) or {}
except Exception:
error_data = {}
error_code = error_data.get("code", "")
# Check for rate limit vs quota exceeded
if error_code == "insufficient_quota":
raise OpenAIProviderError(
code=OPENAI_QUOTA_EXCEEDED,
message="Quota OpenAI épuisé. Vérifiez votre facturation.",
details={"status_code": 429, "error_code": error_code},
)
else:
# Rate limit
retry_after = response.headers.get("retry-after", "20")
raise OpenAIProviderError(
code=OPENAI_RATE_LIMITED,
message=f"Limite de requêtes OpenAI atteinte. Réessayez dans {retry_after}s.",
details={
"status_code": 429,
"retry_after_seconds": int(retry_after)
if retry_after.isdigit()
else 20,
},
)
if response.status_code == 400:
try:
error_data = response.json().get("error", {}) or {}
except Exception:
error_data = {}
error_code = error_data.get("code", "")
if error_code == "context_length_exceeded":
raise OpenAIProviderError(
code=OPENAI_CONTEXT_TOO_LONG,
message="Texte trop long pour le modèle (max ~4000 tokens).",
details={"status_code": 400, "error_code": error_code},
)
if response.status_code >= 500:
raise OpenAIProviderError(
code=OPENAI_SERVICE_ERROR,
message="Service OpenAI temporairement indisponible.",
details={"status_code": response.status_code},
)
if response.status_code != 200:
error_text = response.text[:200] if response.text else "Unknown error"
raise OpenAIProviderError(
code=OPENAI_SERVICE_ERROR,
message=f"Erreur OpenAI: {error_text}",
details={"status_code": response.status_code},
)
data = response.json()
choices = data.get("choices", [])
if not choices:
raise OpenAIProviderError(
code=OPENAI_SERVICE_ERROR,
message="Erreur OpenAI: réponse vide",
details={"response": str(data)[:200]},
)
content = choices[0].get("message", {}).get("content", "")
if not content:
raise OpenAIProviderError(
code=OPENAI_SERVICE_ERROR,
message="Erreur OpenAI: réponse vide",
details={"response": str(data)[:200]},
)
usage = data.get("usage", {})
return content.strip(), usage
except Timeout:
raise OpenAIProviderError(
code=OPENAI_TIMEOUT,
message="Délai d'attente OpenAI dépassé. Le service est lent.",
details={"timeout_seconds": self._timeout},
)
except RequestsConnectionError:
raise OpenAIProviderError(
code=OPENAI_SERVICE_ERROR,
message="Service OpenAI temporairement indisponible.",
details={"error": "Connection failed"},
)
except OpenAIProviderError:
raise
except Exception as e:
error_str = str(e).lower()
if "connection" in error_str or "refused" in error_str:
raise OpenAIProviderError(
code=OPENAI_SERVICE_ERROR,
message="Service OpenAI temporairement indisponible.",
details={"original_error": str(e)[:100]},
)
raise OpenAIProviderError(
code=OPENAI_SERVICE_ERROR,
message=f"Erreur OpenAI: {str(e)[:100]}",
details={"original_error": str(e)[:100]},
)
def get_name(self) -> str:
"""Return provider name."""
return self._provider_name
def is_available(self) -> bool:
"""
Check if OpenAI API is available.
Uses cached result if available and not expired.
"""
current_time = time.time()
with self._health_cache_lock:
if "is_available" in self._health_cache:
cached = self._health_cache["is_available"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
try:
url = f"{self._base_url}/models"
headers = {"Authorization": f"Bearer {self._api_key}"}
response = requests.get(
url, headers=headers, timeout=self._health_check_timeout
)
available = response.status_code == 200
except Exception as e:
_log_warning("openai_availability_check_failed", error=str(e)[:100])
available = False
with self._health_cache_lock:
self._health_cache["is_available"] = {
"value": available,
"timestamp": current_time,
}
return available
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
"""
Translate a single text string using OpenAI LLM.
Supports custom system prompt via request.metadata["custom_prompt"].
Args:
request: TranslationRequest with text and language info
Returns:
TranslationResponse with translated text
"""
text = request.text
target_language = request.target_language
source_language = request.source_language or "auto"
if not text or not text.strip():
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
)
source_lang_name = _get_language_name(source_language)
target_lang_name = _get_language_name(target_language)
custom_prompt = None
if request.metadata:
custom_prompt = request.metadata.get("custom_prompt")
system_prompt = _build_system_prompt(
source_lang_name, target_lang_name, custom_prompt
)
last_error: Optional[OpenAIProviderError] = None
retries = 0
while retries <= self._max_retries:
try:
start_time = time.time()
result, usage = self._make_api_request(text, system_prompt)
latency = time.time() - start_time
log_kw: Dict[str, Any] = {
"chars": len(text),
"source_lang": source_language,
"target_lang": target_language,
"model": self._model,
"latency_ms": round(latency * 1000, 2),
"retries": retries,
}
if usage and isinstance(usage.get("total_tokens"), (int, float)):
log_kw["tokens_used"] = usage.get("total_tokens")
_log_info("openai_translation_success", **log_kw)
return TranslationResponse(
translated_text=result,
provider_name=self._provider_name,
from_cache=False,
source_language=source_language,
)
except OpenAIProviderError as e:
last_error = e
if e.code not in _RETRYABLE_ERRORS:
break
retries += 1
if retries <= self._max_retries:
delay = self._retry_delay * (2 ** (retries - 1))
_log_info(
"openai_translation_retry",
attempt=retries,
delay_s=round(delay, 2),
error_code=e.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
)
time.sleep(delay)
except Exception as e:
last_error = OpenAIProviderError(
code=OPENAI_SERVICE_ERROR,
message=f"Erreur OpenAI: {str(e)[:100]}",
details={"original_error": str(e)[:100]},
)
retries += 1
if retries <= self._max_retries:
delay = self._retry_delay * (2 ** (retries - 1))
time.sleep(delay)
if last_error:
_log_error(
"openai_translation_failed",
error_code=last_error.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
retries=retries,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error=last_error.message,
error_code=last_error.code,
error_details=last_error.details,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error="Unknown error",
error_code=OPENAI_SERVICE_ERROR,
)
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
"""
Translate multiple texts.
Args:
requests: List of TranslationRequest objects
Returns:
List of TranslationResponse objects
"""
if not requests:
return []
return [self.translate_text(req) for req in requests]
def health_check(self) -> ProviderHealthStatus:
"""
Return health status details for the provider.
Includes cached result for efficiency.
Returns:
ProviderHealthStatus with availability, latency, and model information
"""
current_time = time.time()
with self._health_cache_lock:
if "health_check" in self._health_cache:
cached = self._health_cache["health_check"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
start_time = time.time()
last_check_iso = datetime.now(timezone.utc).isoformat()
try:
url = f"{self._base_url}/models"
headers = {"Authorization": f"Bearer {self._api_key}"}
response = requests.get(
url, headers=headers, timeout=self._health_check_timeout
)
latency_ms = (time.time() - start_time) * 1000
available = response.status_code == 200
error_msg = None
model_available = None
if available:
try:
models_data = response.json().get("data", [])
model_ids = [m.get("id", "") for m in models_data]
model_available = self._model in model_ids or any(
self._model in mid for mid in model_ids
)
except Exception:
model_available = None
else:
if response.status_code == 401:
error_msg = "Invalid API key"
else:
error_msg = f"OpenAI API returned {response.status_code}"
status = ProviderHealthStatus(
name=self._provider_name,
available=available,
latency_ms=round(latency_ms, 2),
error=error_msg,
last_check=last_check_iso,
model=self._model,
model_available=model_available,
)
except Exception as e:
latency_ms = (time.time() - start_time) * 1000
status = ProviderHealthStatus(
name=self._provider_name,
available=False,
latency_ms=round(latency_ms, 2),
error=str(e)[:100],
last_check=last_check_iso,
model=self._model,
model_available=False,
)
with self._health_cache_lock:
self._health_cache["health_check"] = {
"value": status,
"timestamp": current_time,
}
return status
def register_openai_provider():
"""
Register the OpenAI provider in the global registry.
This function should be called during module initialization
to make the provider available through the registry.
"""
from .registry import registry
provider = get_openai_provider()
registry.register("openai", provider)
return provider
_provider_instance: Optional[OpenAITranslationProvider] = None
_provider_lock = threading.Lock()
def get_openai_provider() -> OpenAITranslationProvider:
"""Get or create the OpenAI provider instance (reads config from env)."""
global _provider_instance
if _provider_instance is None:
with _provider_lock:
if _provider_instance is None:
from .config import ProvidersConfig
_provider_instance = OpenAITranslationProvider(
api_key=ProvidersConfig.OPENAI_API_KEY,
model=ProvidersConfig.OPENAI_MODEL,
timeout=ProvidersConfig.OPENAI_TIMEOUT,
max_retries=ProvidersConfig.OPENAI_MAX_RETRIES,
retry_delay=ProvidersConfig.OPENAI_RETRY_DELAY,
base_url=ProvidersConfig.OPENAI_BASE_URL,
health_check_timeout=ProvidersConfig.OPENAI_HEALTH_CHECK_TIMEOUT,
)
return _provider_instance
def reset_openai_provider() -> None:
"""Reset the OpenAI provider singleton (useful when config changes)."""
global _provider_instance
with _provider_lock:
_provider_instance = None

View File

@@ -0,0 +1,148 @@
"""
Provider Registry - Singleton pattern for managing translation providers.
Provides a central registry for all translation providers with:
- Registration and retrieval by name
- Listing available providers
- Fallback chain support
"""
from typing import Dict, List, Optional
import threading
from .base import TranslationProvider
class ProviderRegistry:
"""
Singleton registry for translation providers.
Thread-safe implementation for managing multiple translation providers
with support for fallback chains.
"""
_instance: Optional["ProviderRegistry"] = None
_lock: threading.Lock = threading.Lock()
def __new__(cls) -> "ProviderRegistry":
"""Create or return the singleton instance."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._providers: Dict[str, TranslationProvider] = {}
cls._instance._providers_lock = threading.RLock()
return cls._instance
def register(self, name: str, provider: TranslationProvider) -> None:
"""
Register a translation provider.
Args:
name: Unique name for the provider (e.g., "google", "deepl")
provider: TranslationProvider instance
"""
with self._providers_lock:
self._providers[name] = provider
def unregister(self, name: str) -> bool:
"""
Unregister a translation provider.
Args:
name: Name of the provider to remove
Returns:
True if provider was removed, False if not found
"""
with self._providers_lock:
if name in self._providers:
del self._providers[name]
return True
return False
def get(self, name: str) -> Optional[TranslationProvider]:
"""
Get a registered provider by name.
Args:
name: Provider name
Returns:
TranslationProvider instance or None if not found
"""
with self._providers_lock:
return self._providers.get(name)
def list_all(self) -> List[str]:
"""
List all registered provider names.
Returns:
List of provider names
"""
with self._providers_lock:
return list(self._providers.keys())
def list_available(self) -> List[str]:
"""
List names of all available (reachable) providers.
Returns:
List of provider names that are currently available
"""
with self._providers_lock:
return [
name
for name, provider in self._providers.items()
if provider.is_available()
]
def get_first_available(self, names: List[str]) -> Optional[TranslationProvider]:
"""
Get the first available provider from a list of names (fallback chain).
Iterates through the list in order and returns the first provider
that is available. This enables graceful degradation when providers
are unavailable.
Args:
names: List of provider names in priority order
Returns:
First available TranslationProvider or None if all are unavailable
"""
for name in names:
provider = self.get(name)
if provider is not None and provider.is_available():
return provider
return None
def clear(self) -> None:
"""Remove all registered providers."""
with self._providers_lock:
self._providers.clear()
def __len__(self) -> int:
"""Return the number of registered providers."""
with self._providers_lock:
return len(self._providers)
def __contains__(self, name: str) -> bool:
"""Check if a provider is registered."""
with self._providers_lock:
return name in self._providers
def get_registry() -> ProviderRegistry:
"""
Get the global provider registry instance.
Returns:
The singleton ProviderRegistry instance
"""
return ProviderRegistry()
# Global registry instance
registry = ProviderRegistry()

View File

@@ -0,0 +1,120 @@
"""
Pydantic models for translation provider request/response schemas.
"""
import re
from typing import Optional, List
from pydantic import BaseModel, ConfigDict, Field, field_validator
LANGUAGE_CODE_PATTERN = re.compile(r"^[a-z]{2}(-[A-Z]{2})?$|^auto$")
class TranslationRequest(BaseModel):
"""Request model for translation operations."""
text: str = Field(..., description="Text to translate")
target_language: str = Field(
..., description="Target language code (e.g., 'en', 'fr', 'es')"
)
source_language: str = Field(
default="auto", description="Source language code (default: auto-detect)"
)
metadata: Optional[dict] = Field(
default=None,
description="Optional metadata for provider-specific options (e.g., custom_prompt)",
)
@field_validator("target_language", "source_language")
@classmethod
def validate_language_code(cls, v: str) -> str:
if not LANGUAGE_CODE_PATTERN.match(v):
raise ValueError(
f"Invalid language code '{v}'. Expected format: 'xx' or 'xx-XX' (e.g., 'en', 'fr', 'en-US')"
)
return v
class TranslationResponse(BaseModel):
"""Response model for translation operations."""
translated_text: str = Field(..., description="Translated text")
provider_name: str = Field(
..., description="Name of the provider that performed the translation"
)
from_cache: bool = Field(
default=False, description="Whether the result came from cache"
)
source_language: Optional[str] = Field(
default=None, description="Detected or specified source language"
)
error: Optional[str] = Field(
default=None, description="Error message if translation failed"
)
error_code: Optional[str] = Field(
default=None, description="Error code for programmatic error handling"
)
error_details: Optional[dict] = Field(
default=None, description="Additional error details"
)
@property
def success(self) -> bool:
"""Check if translation was successful."""
return self.error is None
def to_error_dict(self) -> dict:
"""Convert error to dictionary format for API responses."""
if self.error is None:
return {}
result = {
"error": self.error_code or "UNKNOWN_ERROR",
"message": self.error,
}
if self.error_details:
result["details"] = self.error_details
return result
class BatchTranslationRequest(BaseModel):
"""Request model for batch translation operations."""
texts: List[str] = Field(..., description="List of texts to translate")
target_language: str = Field(..., description="Target language code")
source_language: str = Field(
default="auto", description="Source language code (default: auto-detect)"
)
class BatchTranslationResponse(BaseModel):
"""Response model for batch translation operations."""
translated_texts: List[str] = Field(..., description="List of translated texts")
provider_name: str = Field(
..., description="Name of the provider that performed the translations"
)
from_cache_count: int = Field(default=0, description="Number of results from cache")
class ProviderHealthStatus(BaseModel):
"""Health status model for a translation provider."""
model_config = ConfigDict(protected_namespaces=())
name: str = Field(..., description="Provider name")
available: bool = Field(..., description="Whether the provider is available")
latency_ms: Optional[float] = Field(
default=None, description="Response latency in milliseconds"
)
error: Optional[str] = Field(
default=None, description="Error message if unavailable"
)
last_check: Optional[str] = Field(
default=None, description="ISO timestamp of last health check"
)
model: Optional[str] = Field(
default=None, description="Model name (e.g. for LLM providers)"
)
model_available: Optional[bool] = Field(
default=None, description="Whether the configured model is available"
)

149
services/storage_tracker.py Normal file
View File

@@ -0,0 +1,149 @@
import os
import json
import logging
from datetime import datetime, timezone
from typing import Optional, Any, Dict
from config import config
try:
import structlog
logger = structlog.get_logger(__name__)
_HAS_STRUCTLOG = True
except ImportError:
logger = logging.getLogger(__name__)
_HAS_STRUCTLOG = False
def _log_info(event: str, **kwargs):
"""Log info with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.info(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.info(msg)
def _log_error(event: str, **kwargs):
"""Log error with structlog or standard logging compatibility."""
if _HAS_STRUCTLOG:
logger.error(event, **kwargs)
else:
msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
logger.error(msg)
# Key pattern: translation:file:{job_id}
KEY_PREFIX = "translation:file"
def _get_default_ttl() -> int:
"""Get TTL from config or default to 60 minutes."""
try:
from config import config
return config.FILE_TTL_MINUTES * 60
except Exception:
return 3600 # 60 minutes default
_async_redis = None
def _get_async_redis():
"""Return async Redis client or None. Uses REDIS_URL from env."""
global _async_redis
if _async_redis is not None:
return _async_redis if _async_redis is not False else None
# Try to get from environment first
url = os.getenv("REDIS_URL", "").strip()
if not url:
_async_redis = False
return None
try:
import redis.asyncio as redis
_async_redis = redis.Redis.from_url(url, decode_responses=True)
_log_info("redis_connected", service="storage_tracker")
return _async_redis
except Exception as e:
_log_error("redis_connection_failed", service="storage_tracker", error=str(e))
_async_redis = False
return None
class StorageTracker:
"""
Tracks file locations and metadata in Redis.
Pattern: translation:file:{job_id} -> JSON metadata
"""
def __init__(self):
self._redis = None
def _redis_client(self):
if self._redis is None:
self._redis = _get_async_redis()
return self._redis
async def track_file(
self, job_id: str, metadata: Dict[str, Any], ttl: Optional[int] = None
) -> bool:
"""
Store file metadata in Redis with TTL and log the upload.
"""
if ttl is None:
ttl = _get_default_ttl()
# Ensure timestamp is present
if "timestamp" not in metadata:
metadata["timestamp"] = datetime.now(timezone.utc).isoformat()
# Log metadata (no content)
_log_info(
"file_uploaded",
job_id=job_id,
original_filename=metadata.get("original_filename"),
file_size=metadata.get("file_size"),
file_hash=metadata.get("file_hash"),
user_id=metadata.get("user_id"),
timestamp=metadata.get("timestamp"),
)
redis_client = self._redis_client()
if not redis_client:
_log_error(
"redis_not_available", job_id=job_id, hint="File tracked in logs only"
)
return False
try:
key = f"{KEY_PREFIX}:{job_id}"
await redis_client.set(key, json.dumps(metadata), ex=ttl)
_log_info("file_tracked_in_redis", job_id=job_id, ttl_seconds=ttl)
return True
except Exception as e:
_log_error("redis_track_failed", job_id=job_id, error=str(e))
return False
async def get_file_metadata(self, job_id: str) -> Optional[Dict[str, Any]]:
"""
Retrieve file metadata from Redis.
"""
redis_client = self._redis_client()
if not redis_client:
return None
try:
key = f"{KEY_PREFIX}:{job_id}"
data = await redis_client.get(key)
return json.loads(data) if data else None
except Exception as e:
_log_error("redis_get_failed", job_id=job_id, error=str(e))
return None
# Singleton for app use
storage_tracker = StorageTracker()

File diff suppressed because it is too large Load Diff