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

383 lines
12 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.
"""
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
from core.logging import get_logger
logger = get_logger(__name__)
# Detect structlog rather than hardcoding True, so the stdlib-logging fallback
# branches below stay 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)
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]:
if not texts:
return []
from .config import ProvidersConfig
provider_names = ProvidersConfig.get_fallback_chain(self._mode)
requests = [
TranslationRequest(
text=t,
target_language=target_language,
source_language=source_language,
)
for t in texts
]
# Try each provider once with a full batch (avoids N× fallback chain walks).
for provider_name in provider_names:
p = registry.get(provider_name)
if p is None:
continue
if not p.is_available():
continue
try:
responses = p.translate_batch(requests)
except Exception:
continue
if len(responses) != len(requests):
continue
if all(r.error is None for r in responses):
self._last_provider_used = provider_name
return [
r.translated_text if r.translated_text is not None else texts[i]
for i, r in enumerate(responses)
]
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