Files
office_translator/services/providers/deepseek_provider.py
sepehr 526c87348f
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s
feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
Translation quality & format preservation:
- Word: merge adjacent same-format runs into one unit (sentence-level
  coherence like inline-tag handling); translate comments/balloons;
  dedupe textbox collection (was translated twice); RTL no longer
  overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs)
- PPTX: chart translations now actually reach the output file
  (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP);
  CJK typeface hints (a:ea)
- Excel: sheet renames no longer break references — rewrite cell
  formulas (3D/quoted), defined names, data validations, cond. formats
- PDF: bold/italic honored (hebo/heit/hebi); table cells never merge;
  unchanged blocks left untouched (typography preserved, fixes duplicate
  hyperlinks); attempted/changed stats + route gate now cover PDF;
  CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings)

Features:
- formality param (formal/informal) + automatic regional-variant prompts
- output_mode=bilingual docx (source above translation)
- per-user translation memory on Redis (falls back to LRU), context-hashed
- QA report + 0-100 confidence score in job status; L0 on by default
- OpenAI-compatible providers: whole chunk in ONE numbered-JSON request
  (~15x fewer calls) with per-item fallback; base prompt always present
  (custom prompt no longer replaces translation instructions)

Infra & marketing alignment:
- plan-based engine gating + vision gating (closes paid-engine leak);
  /providers/available filtered per plan; 107 languages exposed
- zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash)
- admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin
  (TestClient breakage); Prometheus test fixture fixed
- marketing docs aligned with code (PDF+OCR, retention, engines, pricing)
- security: .env.ionos/.env.production/provider_settings.json removed

Tests: 1173 passed / 0 failed (6 network tests deselected: free Google
endpoint temporarily blocked from this machine)
2026-08-29 18:38:09 +02:00

252 lines
10 KiB
Python

"""
DeepSeek Provider - Cloud LLM translation via DeepSeek API.
DeepSeek uses an OpenAI-compatible Chat Completions API.
"""
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import requests
from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError
from core.logging import get_logger
from .base import TranslationProvider
from .schemas import ProviderHealthStatus, TranslationRequest, TranslationResponse
logger = get_logger(__name__)
DEEPSEEK_RATE_LIMITED = "DEEPSEEK_RATE_LIMITED"
DEEPSEEK_INVALID_KEY = "DEEPSEEK_INVALID_KEY"
DEEPSEEK_TIMEOUT = "DEEPSEEK_TIMEOUT"
DEEPSEEK_SERVICE_ERROR = "DEEPSEEK_SERVICE_ERROR"
_RETRYABLE_ERRORS = {DEEPSEEK_RATE_LIMITED, DEEPSEEK_TIMEOUT, DEEPSEEK_SERVICE_ERROR}
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
- Translate technical terms, jargon, and labels using the standard target-language equivalent
- Chart elements (titles, axis labels, legend entries, category labels, series names) MUST always be translated, even when they look like a title or a proper noun
- Translate month and weekday abbreviations (Jan → janvier, Mon → lundi) to the target language
- Keep ONLY real proper nouns unchanged: people's names, place names, company names (Google, Microsoft), and product names (GitHub, HuggingFace)
- Do not invent content; if a term is an acronym with no translation (API, URL, HTTP, JSON), keep it as-is"""
def _get_language_name(code: str) -> str:
"""Convert language code to full name (all supported languages)."""
from core.languages import language_name
return language_name(code)
class DeepSeekProviderError(Exception):
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)
class DeepSeekTranslationProvider(TranslationProvider):
"""
DeepSeek translation provider using OpenAI-compatible API.
Supports DeepSeek Chat and DeepSeek Reasoner models.
"""
def __init__(
self,
api_key: str,
model: str = "deepseek-chat",
timeout: int = 60,
max_retries: int = 3,
retry_delay: float = 1.0,
base_url: str = "https://api.deepseek.com/v1",
):
if not api_key or not api_key.strip():
raise ValueError("DeepSeek API key cannot be empty")
self._api_key = api_key
self._model = model
self._base_url = base_url.rstrip("/")
self._provider_name = "deepseek"
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()
def _make_api_request(self, text: str, system_prompt: str) -> tuple:
if not text or not text.strip():
return text, {}
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)
if response.status_code == 401:
raise DeepSeekProviderError(DEEPSEEK_INVALID_KEY, "Cle API DeepSeek invalide.")
if response.status_code == 429:
raise DeepSeekProviderError(DEEPSEEK_RATE_LIMITED, "Limite de requetes DeepSeek atteinte.")
if response.status_code >= 500:
raise DeepSeekProviderError(DEEPSEEK_SERVICE_ERROR, "Service DeepSeek temporairement indisponible.")
if response.status_code != 200:
raise DeepSeekProviderError(DEEPSEEK_SERVICE_ERROR, f"Erreur DeepSeek: {response.text[:200]}")
data = response.json()
choices = data.get("choices", [])
if not choices:
raise DeepSeekProviderError(DEEPSEEK_SERVICE_ERROR, "Reponse DeepSeek vide")
content = choices[0].get("message", {}).get("content", "")
if not content:
raise DeepSeekProviderError(DEEPSEEK_SERVICE_ERROR, "Reponse DeepSeek vide")
return content.strip(), data.get("usage", {})
except Timeout:
raise DeepSeekProviderError(DEEPSEEK_TIMEOUT, "Delai d'attente DeepSeek depasse.")
except RequestsConnectionError:
raise DeepSeekProviderError(DEEPSEEK_SERVICE_ERROR, "Service DeepSeek indisponible.")
except DeepSeekProviderError:
raise
except Exception as e:
raise DeepSeekProviderError(DEEPSEEK_SERVICE_ERROR, f"Erreur DeepSeek: {str(e)[:100]}")
def get_name(self) -> str:
return self._provider_name
def is_available(self) -> bool:
try:
headers = {"Authorization": f"Bearer {self._api_key}"}
response = requests.get(f"{self._base_url}/models", headers=headers, timeout=5)
return response.status_code == 200
except Exception:
return False
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
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 = request.metadata.get("custom_prompt") if request.metadata else None
_base_prompt = DEFAULT_TRANSLATION_PROMPT.format(
source_lang=source_lang_name, target_lang=target_lang_name
)
# Base translation instructions always present; the custom prompt
# (glossary/tone/context) is appended, never a replacement.
if custom_prompt and custom_prompt.strip():
system_prompt = (
_base_prompt
+ "\n\nADDITIONAL CONTEXT AND INSTRUCTIONS:\n"
+ custom_prompt.strip()
)
else:
system_prompt = _base_prompt
last_error = None
for attempt in range(self._max_retries + 1):
try:
start_time = time.time()
result, usage = self._make_api_request(text, system_prompt)
latency = time.time() - start_time
logger.info("deepseek_translation_success",
chars=len(text), source_lang=source_language, target_lang=target_language,
model=self._model, latency_ms=round(latency * 1000, 2), retries=attempt)
return TranslationResponse(
translated_text=result, provider_name=self._provider_name,
from_cache=False, source_language=source_language)
except DeepSeekProviderError as e:
last_error = e
if e.code not in _RETRYABLE_ERRORS or attempt >= self._max_retries:
break
delay = self._retry_delay * (2 ** attempt)
time.sleep(delay)
return TranslationResponse(
translated_text=text, provider_name=self._provider_name, from_cache=False,
error=last_error.message if last_error else "Unknown error",
error_code=last_error.code if last_error else DEEPSEEK_SERVICE_ERROR)
def translate_batch(self, requests: List[TranslationRequest]) -> List[TranslationResponse]:
return [self.translate_text(req) for req in requests]
def health_check(self) -> ProviderHealthStatus:
start_time = time.time()
try:
headers = {"Authorization": f"Bearer {self._api_key}"}
response = requests.get(f"{self._base_url}/models", headers=headers, timeout=5)
latency_ms = (time.time() - start_time) * 1000
return ProviderHealthStatus(
name=self._provider_name, available=response.status_code == 200,
latency_ms=round(latency_ms, 2), last_check=datetime.now(timezone.utc).isoformat(),
model=self._model)
except Exception as e:
return ProviderHealthStatus(
name=self._provider_name, available=False,
latency_ms=round((time.time() - start_time) * 1000, 2),
error=str(e)[:100], last_check=datetime.now(timezone.utc).isoformat(),
model=self._model)
_provider_instance: Optional[DeepSeekTranslationProvider] = None
_provider_lock = threading.Lock()
def get_deepseek_provider() -> DeepSeekTranslationProvider:
global _provider_instance
if _provider_instance is None:
with _provider_lock:
if _provider_instance is None:
from .config import ProvidersConfig
_provider_instance = DeepSeekTranslationProvider(
api_key=ProvidersConfig.DEEPSEEK_API_KEY,
model=ProvidersConfig.DEEPSEEK_MODEL,
timeout=ProvidersConfig.DEEPSEEK_TIMEOUT,
max_retries=ProvidersConfig.DEEPSEEK_MAX_RETRIES,
retry_delay=ProvidersConfig.DEEPSEEK_RETRY_DELAY,
base_url=ProvidersConfig.DEEPSEEK_BASE_URL,
)
return _provider_instance
def register_deepseek_provider():
from .registry import registry
provider = get_deepseek_provider()
registry.register("deepseek", provider)
return provider
def reset_deepseek_provider():
global _provider_instance
with _provider_lock:
_provider_instance = None