Files
office_translator/services/providers/openai_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

746 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
from core.logging import get_logger
logger = get_logger(__name__)
# structlog is the project's logger backend (see core/logging.py), but detect
# it rather than hardcoding True so the stdlib-logging fallback branches below
# are reachable if structlog is ever absent.
try:
import structlog # noqa: F401
_HAS_STRUCTLOG = True
except ImportError: # pragma: no cover - structlog is a hard project dep
_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
- 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 _build_system_prompt(
source_lang: str, target_lang: str, custom_prompt: Optional[str] = None
) -> str:
"""Build system prompt for translation.
The base translation instructions are ALWAYS present — a custom prompt
(glossary, tone, context) is appended as additional directives, never a
replacement. Previously a glossary-only custom prompt produced a system
prompt with no translation instruction at all.
"""
base = DEFAULT_TRANSLATION_PROMPT.format(
source_lang=source_lang, target_lang=target_lang
)
if custom_prompt and custom_prompt.strip():
return f"{base}\n\nADDITIONAL CONTEXT AND INSTRUCTIONS:\n{custom_prompt.strip()}"
return base
def _get_language_name(code: str) -> str:
"""Convert language code to full name for better LLM understanding."""
from core.languages import language_name
return language_name(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 _make_batch_api_request(
self, texts: List[str], system_prompt: str
) -> Optional[List[str]]:
"""Translate a whole chunk in ONE request via a numbered JSON list.
The user message is a JSON array; the model must answer with a JSON
array of the same length. Returns None when the answer cannot be
parsed confidently — callers then fall back to per-item calls
(correctness over latency).
"""
import json as _json
numbered = _json.dumps(
[{"id": i, "text": t} for i, t in enumerate(texts)],
ensure_ascii=False,
)
batch_system = (
system_prompt
+ "\n\nBATCH MODE: the user message is a JSON array of items with "
"unique ids. Answer with ONLY a JSON array of objects "
'[{"id": <same id>, "translation": "<translated text>"}], same '
"length and same ids, in the same order. Translate every item; "
"keep ids unchanged; no comments, no markdown fence."
)
try:
content, _usage = self._make_api_request(numbered, batch_system)
raw = content.strip()
# Strip an optional markdown fence
if raw.startswith("```"):
raw = raw.strip("`")
if raw.lower().startswith("json"):
raw = raw[4:]
raw = raw.strip()
parsed = _json.loads(raw)
if not isinstance(parsed, list) or len(parsed) != len(texts):
return None
out: List[str] = [""] * len(texts)
for item in parsed:
if not isinstance(item, dict):
return None
idx = item.get("id")
translation = item.get("translation")
if not isinstance(idx, int) or not 0 <= idx < len(texts):
return None
if not isinstance(translation, str) or not translation.strip():
return None
out[idx] = translation.strip()
return out
except OpenAIProviderError:
raise
except Exception:
return None
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
"""
Translate multiple texts.
Chunks arrive from the translators as ~15 texts. When every request
shares the same language pair and metadata, they are sent in ONE
call (numbered JSON list — ~15× fewer requests, better contextual
consistency across neighbouring segments). Any parse/API doubt falls
back to the per-item path so a batch failure never corrupts output.
"""
if not requests:
return []
same_pair = len({(r.source_language, r.target_language) for r in requests}) == 1
same_meta = len(
{tuple(sorted((r.metadata or {}).items())) for r in requests}
) == 1
if same_pair and same_meta and len(requests) > 1:
try:
source_lang_name = _get_language_name(
requests[0].source_language or "auto"
) or "the source language (auto-detect)"
target_lang_name = _get_language_name(requests[0].target_language)
custom_prompt = None
if requests[0].metadata:
custom_prompt = requests[0].metadata.get("custom_prompt")
system_prompt = _build_system_prompt(
source_lang_name, target_lang_name, custom_prompt
)
texts = [r.text for r in requests]
translations = self._make_batch_api_request(texts, system_prompt)
if translations is not None:
_log_info(
"openai_batch_translation_success",
items=len(requests),
model=self._model,
)
return [
TranslationResponse(
translated_text=t,
provider_name=self._provider_name,
from_cache=False,
)
for t in translations
]
_log_warning(
"openai_batch_translation_fallback",
reason="unparseable_response",
items=len(requests),
)
except Exception as e:
_log_warning(
"openai_batch_translation_fallback",
reason=type(e).__name__,
items=len(requests),
)
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