Files
office_translator/services/providers/deepseek_provider.py
sepehr 07c4c12e6a
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m19s
fix(translate): chart labels, sheet-refs, sheet-name offset, paid-user Memento
Multiple translation bugs in Word/Excel/PPTX that caused chart elements
to be left untranslated or charts to render as empty series.

Backend
-------
* providers (deepseek/openai/minimax): tighten system prompt so the LLM
  actually translates chart titles/axis labels/legend/category labels,
  month abbreviations, and clarifies what counts as a 'real' proper noun
  (people/place/company/product names) vs. technical labels. Old rule
  'keep proper nouns unchanged' was being read too broadly by the model
  and caused chart text to be skipped.
* excel_translator.py:
  - Sheet reference rewrite: when a sheet is renamed, the chart XML's
    c:f refs (e.g. 'Sales 2024'!$D$2:$D$61) are now rewritten to the
    new name with proper apostrophe escaping (Chiffre d'affaires ->
    'Chiffre d''affaires') and auto-quoting when the new name contains
    spaces or special chars. Without this, the chart points at a sheet
    that no longer exists and renders 0/empty series.
  - Sheet name offset bug: sheet_name_offset was computed after chart
    text was appended to text_elements, causing sheet names to receive
    chart text translations. Now captured BEFORE sheet names are added.
* New tests:
  - test_excel_chart_sheet_refs.py (10 unit tests, synthetic inputs)
  - test_chart_translation_prompt.py (3 contract tests on the prompt)

Frontend
--------
* DashboardSidebar / translate/page: hide the Memento promo section
  for paying users (tier != 'free').
* constants.ts: temporarily comment out the 'CLES API' nav item.
  Update constants.test.ts to match the new state.

All fixes are generic - no file-specific hardcoding, edge cases covered
(empty mapping, missing bang, apostrophe escaping, partial renames,
multi-series).
2026-07-15 22:04:40 +02:00

248 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:
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", "uk": "Ukrainian", "cs": "Czech", "sv": "Swedish",
"ro": "Romanian", "hu": "Hungarian", "el": "Greek", "he": "Hebrew",
}
return language_names.get(code.split("-")[0].lower(), 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
system_prompt = custom_prompt or DEFAULT_TRANSLATION_PROMPT.format(
source_lang=source_lang_name, target_lang=target_lang_name
)
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