feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s

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)
This commit is contained in:
2026-08-29 18:38:09 +02:00
parent 992f13d53c
commit 526c87348f
87 changed files with 6996 additions and 1024 deletions

View File

@@ -9,6 +9,7 @@ Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
import os
import re
import secrets
import uuid
import time
import socket
@@ -43,7 +44,7 @@ from typing_extensions import Annotated
from config import config
from translators import ExcelTranslator, WordTranslator, PowerPointTranslator
from models.subscription import PlanType
from models.subscription import PlanType, PLANS
from services.auth_service import (
record_usage,
check_usage_limits,
@@ -54,6 +55,7 @@ from middleware.tier_quota import _seconds_until_next_month, _next_month_utc
from middleware.validation import FileValidator, ValidationError, LanguageValidator, webhook_validator
from middleware.api_key_auth import get_authenticated_user, get_user_from_api_key
from utils import file_handler
from utils.file_handler import validate_zip_safety
# Import models from schemas (Story 3.6 - DRY principle)
from schemas.translation import (
@@ -148,6 +150,43 @@ def _tier_for_quota(plan) -> str:
return "free"
def _tier_from_plan_str(plan_str: Optional[str]) -> str:
"""Map the job's ``user_plan`` string to a quota tier.
``user_plan`` is built with ``str(current_user.plan)`` which renders a
str-Enum as "PlanType.PRO" — accept both that repr and the raw value.
"""
name = (plan_str or "").strip().lower()
if name.startswith("plantype."):
name = name.split(".", 1)[1]
try:
return _tier_for_quota(PlanType(name))
except ValueError:
return "free"
def _plan_from_user(current_user: Optional[Any]) -> PlanType:
"""Resolve the request's plan (anonymous requests are on the Free plan)."""
if current_user is None:
return PlanType.FREE
plan = getattr(current_user, "plan", PlanType.FREE)
try:
return PlanType(plan)
except ValueError:
return PlanType.FREE
def _allowed_providers_for_plan(plan: PlanType) -> set:
"""Engines included in the plan (source of truth: PLANS[plan]["providers"])."""
plan_cfg = PLANS.get(plan) or PLANS[PlanType.FREE]
return set(plan_cfg.get("providers", []))
def _image_translation_allowed_for_plan(plan: PlanType) -> bool:
"""Vision (text-in-images) is sold on Pro and above."""
return plan in (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE)
def _next_midnight_utc() -> datetime:
"""Get next midnight UTC."""
now = datetime.now(timezone.utc)
@@ -215,6 +254,21 @@ def _parse_content_disposition(content_disp: str) -> Optional[str]:
return None
def _sanitize_url_filename(filename: str) -> str:
"""Strip path traversal / control chars from a remote-provided filename."""
filename = Path(filename).name
filename = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", filename)
filename = re.sub(r'[<>:"/\\|?*]', "_", filename)
# Drop leading dots (hidden files, ".." leftovers)
filename = filename.lstrip(".")
if len(filename) > 255:
name, ext = (
filename.rsplit(".", 1) if "." in filename else (filename, "")
)
filename = name[:250] + ("." + ext if ext else "")
return filename or "downloaded_file"
def _is_ssrf_risk(hostname: str) -> bool:
"""Return True if hostname resolves to a private/reserved IP (SSRF prevention).
@@ -252,95 +306,130 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
details={"scheme": parsed_url.scheme or "none"},
)
hostname = parsed_url.hostname or ""
if not hostname or _is_ssrf_risk(hostname):
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message="The URL points to a blocked address (private or internal network).",
details={"reason": "ssrf_blocked"},
)
def _validate_url_target(u: str):
p = urlparse(u)
if p.scheme not in ("http", "https"):
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message="Only HTTP/HTTPS URLs are accepted.",
details={"scheme": p.scheme or "none"},
)
h = p.hostname or ""
if not h or _is_ssrf_risk(h):
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message="The URL points to a blocked address (private or internal network).",
details={"reason": "ssrf_blocked"},
)
_validate_url_target(url)
MAX_REDIRECTS = 5
try:
async with httpx.AsyncClient(
timeout=timeout, follow_redirects=True, max_redirects=10
timeout=timeout, follow_redirects=False
) as client:
async with client.stream("GET", url) as response:
if response.status_code != 200:
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message=f"URL unreachable (HTTP {response.status_code})",
details={"status_code": response.status_code, "url": url[:100]},
)
content_length = response.headers.get("content-length")
if content_length:
try:
file_size = int(content_length)
max_size_bytes = MAX_FILE_SIZE_MB * 1024 * 1024
if file_size > max_size_bytes:
current_url = url
for _ in range(MAX_REDIRECTS + 1):
_validate_url_target(current_url)
async with client.stream("GET", current_url) as response:
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location", "")
if not location:
raise TranslateEndpointError(
code=TranslateEndpointError.FILE_TOO_LARGE,
message=f"File is too large ({round(file_size / (1024 * 1024), 2)} MB, max {MAX_FILE_SIZE_MB} MB).",
details={
"size_mb": round(file_size / (1024 * 1024), 2),
"max_mb": MAX_FILE_SIZE_MB,
},
code=TranslateEndpointError.URL_UNREACHABLE,
message="Redirect without Location header.",
details={"reason": "invalid_redirect"},
)
except ValueError:
pass
# Re-check the redirect target before following it
current_url = str(httpx.URL(current_url).join(location))
continue
filename = None
content_disp = response.headers.get("content-disposition", "")
if content_disp:
filename = _parse_content_disposition(content_disp)
if response.status_code != 200:
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message=f"URL unreachable (HTTP {response.status_code})",
details={"status_code": response.status_code, "url": url[:100]},
)
if not filename:
filename = unquote(Path(parsed_url.path).name) or "downloaded_file"
content_length = response.headers.get("content-length")
if content_length:
try:
file_size = int(content_length)
max_size_bytes = MAX_FILE_SIZE_MB * 1024 * 1024
if file_size > max_size_bytes:
raise TranslateEndpointError(
code=TranslateEndpointError.FILE_TOO_LARGE,
message=f"File is too large ({round(file_size / (1024 * 1024), 2)} MB, max {MAX_FILE_SIZE_MB} MB).",
details={
"size_mb": round(file_size / (1024 * 1024), 2),
"max_mb": MAX_FILE_SIZE_MB,
},
)
except ValueError:
pass
extension = Path(filename).suffix.lower()
if extension not in ACCEPTED_EXTENSIONS:
raise TranslateEndpointError(
code=TranslateEndpointError.INVALID_FORMAT,
details={
"detected_extension": extension or "none",
"accepted_formats": list(ACCEPTED_EXTENSIONS),
},
)
filename = None
content_disp = response.headers.get("content-disposition", "")
if content_disp:
filename = _parse_content_disposition(content_disp)
unique_id = str(uuid.uuid4())[:8]
safe_filename = f"{unique_id}_{filename}"
temp_path = config.UPLOAD_DIR / safe_filename
if not filename:
filename = unquote(Path(urlparse(current_url).path).name)
temp_path.parent.mkdir(parents=True, exist_ok=True)
filename = _sanitize_url_filename(filename)
max_size_bytes = MAX_FILE_SIZE_MB * 1024 * 1024
downloaded_bytes = 0
extension = Path(filename).suffix.lower()
if extension not in ACCEPTED_EXTENSIONS:
raise TranslateEndpointError(
code=TranslateEndpointError.INVALID_FORMAT,
details={
"detected_extension": extension or "none",
"accepted_formats": list(ACCEPTED_EXTENSIONS),
},
)
async with aiofiles.open(temp_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=65536):
downloaded_bytes += len(chunk)
unique_id = str(uuid.uuid4())[:8]
safe_filename = f"{unique_id}_{filename}"
temp_path = config.UPLOAD_DIR / safe_filename
if downloaded_bytes > max_size_bytes:
await f.close()
if temp_path.exists():
temp_path.unlink()
raise TranslateEndpointError(
code=TranslateEndpointError.FILE_TOO_LARGE,
details={
"size_mb": round(
downloaded_bytes / (1024 * 1024), 2
),
"max_mb": MAX_FILE_SIZE_MB,
},
)
temp_path.parent.mkdir(parents=True, exist_ok=True)
await f.write(chunk)
max_size_bytes = MAX_FILE_SIZE_MB * 1024 * 1024
downloaded_bytes = 0
async with aiofiles.open(temp_path, "rb") as f:
header = await f.read(4)
await validate_file_content(header, extension)
async with aiofiles.open(temp_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=65536):
downloaded_bytes += len(chunk)
return temp_path, filename
if downloaded_bytes > max_size_bytes:
await f.close()
if temp_path.exists():
temp_path.unlink()
raise TranslateEndpointError(
code=TranslateEndpointError.FILE_TOO_LARGE,
details={
"size_mb": round(
downloaded_bytes / (1024 * 1024), 2
),
"max_mb": MAX_FILE_SIZE_MB,
},
)
await f.write(chunk)
async with aiofiles.open(temp_path, "rb") as f:
header = await f.read(4)
await validate_file_content(header, extension)
return temp_path, filename
raise TranslateEndpointError(
code=TranslateEndpointError.URL_UNREACHABLE,
message="Too many redirects.",
details={"reason": "too_many_redirects", "max_redirects": MAX_REDIRECTS},
)
except httpx.TimeoutException:
if temp_path and temp_path.exists():
@@ -420,9 +509,13 @@ def _cleanup_old_jobs() -> None:
return
_last_cleanup_ts = current_time
# Snapshot the items before filtering: concurrent coroutines (background
# translation workers, status pollers) mutate _translation_jobs, and
# iterating a dict while it is resized raises
# "RuntimeError: dictionary changed size during iteration".
expired_job_ids = [
job_id
for job_id, job in _translation_jobs.items()
for job_id, job in list(_translation_jobs.items())
if job.get("status") in ("completed", "failed")
and (
(ts := job.get("completed_at") or job.get("failed_at"))
@@ -444,6 +537,68 @@ def _job_age_seconds(timestamp_str: str) -> float:
return 0.0
def _provider_model(provider: Any) -> str:
"""Best-effort read of a provider's model name.
The new-style providers (``services/providers/*``) store the model in
``self._model``, while the legacy providers (``services/translation_service``)
expose ``self.model``. Read either so cost-factor logic works for both.
"""
if provider is None:
return ""
return (
getattr(provider, "_model", None)
or getattr(provider, "model", None)
or ""
)
def _compute_cost_factor(provider: Any, provider_name: str = "") -> int:
"""Billing cost factor (1 = standard, 5 = premium).
Premium models (Claude, GPT-4 family, etc.) cost more and are billed at a
higher factor. Cheap variants are explicitly downgraded to 1 (``haiku``,
and the small GPT-4 models such as ``gpt-4o-mini`` / ``gpt-4o-nano``).
The provider may be passed by instance (model read off it) or only by
name (e.g. the ``openrouter_premium`` alias).
"""
model_lower = _provider_model(provider).lower()
provider_lower = (provider_name or "").lower()
if "haiku" in model_lower or "mini" in model_lower or "nano" in model_lower:
return 1
if any(k in model_lower for k in ["claude", "fable", "gpt-4"]) or provider_lower == "openrouter_premium":
return 5
return 1
def _compute_duration_seconds(created_at_iso: str) -> float:
"""Elapsed seconds since ``created_at`` (UTC ISO string).
Uses a timezone-aware timestamp() to avoid the local-time bug that
``time.mktime`` introduced. Falls back to 0 on parse errors so a bad
timestamp can never flip a successful job into the error branch.
"""
return _job_age_seconds(created_at_iso)
async def _release_quota_if_needed(user_id, usage_recorded: bool, job_id: str) -> None:
"""Release the reserved translation quota on a soft-failure path.
The translation worker reserves a quota slot at request time. The generic
``except`` branch releases it on hard failures, but the early ``return``
paths (empty output / no translatable text / 0 texts translated) are NOT
exceptions and must release the quota explicitly — otherwise the user
loses a slot without receiving a translation.
"""
if user_id and not usage_recorded:
try:
await asyncio.to_thread(release_translation_quota, user_id)
logger.info(f"Job {job_id}: released reserved quota after soft-failure")
except Exception as release_err:
logger.exception(f"Job {job_id}: failed to release reserved quota: {release_err}")
@router_v1.post(
"/translate",
response_model=TranslateResponse,
@@ -476,6 +631,14 @@ async def translate_document_v1(
pdf_mode: Optional[Literal["layout", "text_only"]] = Form(
default=None, description="PDF translation mode: 'layout' (preserve layout) or 'text_only' (clean text output). PDF only."
),
formality: Optional[Literal["formal", "informal"]] = Form(
default=None,
description="Tone override for LLM engines: 'formal' or 'informal'. Ignored by classic engines.",
),
output_mode: Optional[Literal["single", "bilingual"]] = Form(
default="single",
description="Output mode: 'single' (translated file only) or 'bilingual' (docx with source + translation interleaved).",
),
translate_images: bool = Form(
default=False, description="Translate text inside images using AI vision"
),
@@ -676,7 +839,9 @@ async def translate_document_v1(
rate_limit_remaining = -1
try:
LanguageValidator.validate(target_lang)
# Keep the canonical form (e.g. "zh-CN") so providers receive a
# code they understand instead of the raw user input.
target_lang = LanguageValidator.validate(target_lang)
except ValidationError as e:
raise TranslateEndpointError(
code="INVALID_FORMAT",
@@ -686,7 +851,7 @@ async def translate_document_v1(
if source_lang and source_lang != "auto":
try:
LanguageValidator.validate(source_lang)
source_lang = LanguageValidator.validate(source_lang)
except ValidationError:
raise TranslateEndpointError(
code="INVALID_FORMAT",
@@ -760,7 +925,23 @@ async def translate_document_v1(
details={"error": "sha256_calculation_failed"},
)
# Office files are ZIP archives: reject archives that expand far
# beyond their uploaded size (zip bomb protection).
if file_extension != ".pdf":
try:
validate_zip_safety(input_path)
except ValueError as e:
file_handler_util.cleanup_file(input_path)
raise TranslateEndpointError(
code=TranslateEndpointError.CORRUPTED_FILE,
message="The file is invalid or expands dangerously.",
details={"reason": "unsafe_archive", "detail": str(e)[:200]},
)
job_id = f"tr_{uuid.uuid4().hex[:12]}"
# Secret per-job token: required to follow/download a job that has no
# logged-in owner (anonymous API calls).
job_access_token = secrets.token_urlsafe(24)
# Track file metadata in Redis with TTL
await storage_tracker.track_file(
@@ -771,6 +952,7 @@ async def translate_document_v1(
"file_hash": file_hash,
"input_path": str(input_path),
"user_id": str(user_id) if user_id else None,
"access_token": job_access_token,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
@@ -794,6 +976,7 @@ async def translate_document_v1(
"target_lang": target_lang,
"created_at": datetime.now(timezone.utc).isoformat(),
"user_id": user_id,
"access_token": job_access_token,
"input_path": str(input_path),
"file_extension": file_extension,
"provider": provider or mode,
@@ -803,6 +986,8 @@ async def translate_document_v1(
"prompt_id": prompt_id, # Story 3.12: Store prompt_id
"pdf_mode": pdf_mode, # PDF translation mode
"translate_images": translate_images,
"formality": formality, # LLM tone override
"output_mode": output_mode or "single", # single | bilingual
}
await set_job_status_async(job_id, _translation_jobs[job_id])
@@ -821,6 +1006,39 @@ async def translate_document_v1(
)
provider_to_use = "google"
# ── Plan-based engine gating (grille commerciale : PLANS[plan]["providers"]) ──
# Chaque plan n'expose que ses moteurs ; un moteur hors plan est refusé
# (403) au lieu d'être exécuté aux frais de la maison.
_user_plan = _plan_from_user(current_user)
if provider_to_use not in _allowed_providers_for_plan(_user_plan):
raise TranslateEndpointError(
code=TranslateEndpointError.PRO_FEATURE_REQUIRED,
message=(
f"Le moteur « {provider_to_use} » n'est pas inclus dans votre plan. "
"Passez à un plan supérieur pour l'utiliser."
),
details={
"feature": "provider",
"provider": provider_to_use,
"plan": str(_user_plan.value),
"allowed_providers": sorted(_allowed_providers_for_plan(_user_plan)),
},
)
# La traduction du texte dans les images (vision) est vendue Pro et plus.
if translate_images and not _image_translation_allowed_for_plan(_user_plan):
raise TranslateEndpointError(
code=TranslateEndpointError.PRO_FEATURE_REQUIRED,
message=(
"La traduction du texte dans les images est réservée "
"aux plans Pro et supérieurs."
),
details={
"feature": "translate_images",
"plan": str(_user_plan.value),
},
)
asyncio.create_task(
_run_translation_job(
job_id=job_id,
@@ -837,6 +1055,8 @@ async def translate_document_v1(
user_plan=str(current_user.plan) if current_user else "free",
pdf_mode=pdf_mode,
translate_images=translate_images,
formality=formality,
output_mode=output_mode or "single",
)
)
@@ -853,6 +1073,8 @@ async def translate_document_v1(
"file_name": original_filename,
"source_lang": source_lang,
"target_lang": target_lang,
# Needed to poll/download this job when calling without login
"access_token": job_access_token,
},
"meta": {
"rate_limit_remaining": rate_limit_remaining,
@@ -980,6 +1202,8 @@ async def _run_translation_job(
user_plan: Optional[str] = None, # Plan name for watermark decision
pdf_mode: Optional[str] = None, # PDF translation mode: "layout" or "text_only"
translate_images: bool = False,
formality: Optional[str] = None, # LLM tone override: formal/informal
output_mode: str = "single", # single | bilingual
) -> None:
"""
Run translation job in background with progress tracking.
@@ -1076,11 +1300,13 @@ async def _run_translation_job(
# Use custom_prompt if no prompt_id
effective_prompt = custom_prompt
# Build the full prompt combining effective prompt and glossary
# Build the full prompt combining effective prompt, glossary,
# formality directive and regional variant hint.
full_prompt = build_full_prompt(
effective_prompt, glossary_terms,
source_lang=glossary_source_lang, target_lang=target_lang,
glossary_target_lang=glossary_target_lang,
formality=formality,
)
from services.providers.google_provider import GoogleTranslationProvider
@@ -1259,6 +1485,8 @@ async def _run_translation_job(
job_translator = ExcelTranslator(provider=translation_provider)
if hasattr(job_translator, "set_custom_prompt"):
job_translator.set_custom_prompt(full_prompt)
if hasattr(job_translator, "set_tm_scope"):
job_translator.set_tm_scope(user_id, full_prompt)
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1272,6 +1500,8 @@ async def _run_translation_job(
job_translator = WordTranslator(provider=translation_provider)
if hasattr(job_translator, "set_custom_prompt"):
job_translator.set_custom_prompt(full_prompt)
if hasattr(job_translator, "set_tm_scope"):
job_translator.set_tm_scope(user_id, full_prompt)
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1285,6 +1515,8 @@ async def _run_translation_job(
job_translator = PowerPointTranslator(provider=translation_provider)
if hasattr(job_translator, "set_custom_prompt"):
job_translator.set_custom_prompt(full_prompt)
if hasattr(job_translator, "set_tm_scope"):
job_translator.set_tm_scope(user_id, full_prompt)
await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1299,6 +1531,40 @@ async def _run_translation_job(
job_translator = PDFTranslator(provider=translation_provider)
if hasattr(job_translator, "set_custom_prompt"):
job_translator.set_custom_prompt(full_prompt)
if hasattr(job_translator, "set_tm_scope"):
job_translator.set_tm_scope(user_id, full_prompt)
# OCR (PDF scannés) : réglages admin > variables d'env.
mistral_cfg = getattr(_admin_cfg, "mistral", None)
mistral_key = _cfg(
getattr(mistral_cfg, "api_key", None), "MISTRAL_API_KEY"
)
# Only honor the admin "enabled" flag when Mistral is actually
# configured there — a freshly saved settings file carries
# enabled=false defaults that must not override env-based OCR.
mistral_admin_configured = bool(
mistral_cfg is not None
and (getattr(mistral_cfg, "api_key", None) or "").strip()
)
job_translator.set_ocr_config(
api_key=mistral_key,
model=_cfg(
getattr(mistral_cfg, "model", None),
"MISTRAL_OCR_MODEL",
"mistral-ocr-latest",
),
timeout=int(
_cfg(
str(getattr(mistral_cfg, "timeout", "") or ""),
"MISTRAL_OCR_TIMEOUT",
"180",
)
),
enabled=(
getattr(mistral_cfg, "enabled", None)
if mistral_admin_configured
else None
),
)
actual_output = await asyncio.to_thread(
job_translator.translate_file,
input_path,
@@ -1320,19 +1586,21 @@ async def _run_translation_job(
error_msg = "Translation failed: output file is empty or missing. The translation provider may be unavailable."
logger.error(f"Job {job_id}: {error_msg}")
tracker.set_error(error_msg)
await _release_quota_if_needed(user_id, usage_recorded, job_id)
return
stats = job_translator.get_translation_stats()
attempted = stats.get("attempted", 0)
changed = stats.get("changed", 0)
if attempted == 0 and file_extension in ('.docx', '.xlsx', '.pptx'):
if attempted == 0:
error_msg = (
"Aucun texte traduisible détecté dans le document. "
"Le fichier est peut-être vide, protégé, ou ne contient que des images."
)
logger.error(f"Job {job_id}: {error_msg}")
tracker.set_error(error_msg)
await _release_quota_if_needed(user_id, usage_recorded, job_id)
return
if attempted > 0:
@@ -1346,6 +1614,7 @@ async def _run_translation_job(
)
logger.error(f"Job {job_id}: {error_msg}")
tracker.set_error(error_msg)
await _release_quota_if_needed(user_id, usage_recorded, job_id)
return
elif ratio < 0.05:
# Very suspicious — likely partial failure, warn but don't block
@@ -1430,9 +1699,7 @@ async def _run_translation_job(
from middleware.metrics import record_translation_retry
record_translation_retry(
reason="l1_fail",
tier=_tier_for_quota(
current_user.plan if current_user else None
) if current_user else "free",
tier=_tier_from_plan_str(user_plan),
)
except Exception:
pass
@@ -1448,9 +1715,7 @@ async def _run_translation_job(
from middleware.metrics import record_translation_retry
record_translation_retry(
reason="l0_fail",
tier=_tier_for_quota(
current_user.plan if current_user else None
) if current_user else "free",
tier=_tier_from_plan_str(user_plan),
)
except Exception:
pass
@@ -1467,9 +1732,7 @@ async def _run_translation_job(
from services.quality import run_l2_check
# Tier gate: Pro+ plans only (unless gate is disabled)
user_tier = (
_tier_for_quota(current_user.plan) if current_user else "free"
)
user_tier = _tier_from_plan_str(user_plan)
tier_gate_on = getattr(config, "QUALITY_L2_TIER_GATE", True)
if not tier_gate_on or user_tier in ("pro", "business", "enterprise"):
translated_chunks_for_l2 = [s["translated"] for s in quality_samples]
@@ -1498,21 +1761,52 @@ async def _run_translation_job(
f"Job {job_id}: quality L2 layer failed: {l2_err}"
)
if user_id:
# Determine cost factor based on selected provider and model
cost_factor = 1
provider_lower = (provider or "").lower()
prov_model = ""
if translation_provider:
prov_model = getattr(translation_provider, "model", "") or ""
prov_model_lower = prov_model.lower()
if any(k in prov_model_lower for k in ["claude", "fable", "gpt-4"]) or provider_lower == "openrouter_premium":
if "haiku" in prov_model_lower:
cost_factor = 1
# ------------------------------------------------------------------
# QA report (pure heuristics — numbers fidelity, untranslated
# ratio, 0-100 score). Never blocks the job; surfaced in the job
# status for the UI/API.
# ------------------------------------------------------------------
try:
from services.quality.qa_report import run_qa_report
qa = await asyncio.to_thread(
run_qa_report, input_path, output_path, target_lang, file_extension
)
if qa:
job["quality"] = qa
except Exception as qa_err:
logger.warning(f"Job {job_id}: QA report failed: {qa_err}")
# ------------------------------------------------------------------
# Bilingual output (docx): interleaves source paragraphs above
# their translation. Falls back silently to the translated file.
# ------------------------------------------------------------------
if output_mode == "bilingual" and file_extension == ".docx":
try:
from translators.bilingual import make_bilingual_docx
bilingual_path = output_path.with_name(
output_path.stem + "_bilingual" + output_path.suffix
)
actual = await asyncio.to_thread(
make_bilingual_docx, input_path, output_path, bilingual_path
)
if actual:
output_path = Path(actual)
logger.info(f"Job {job_id}: bilingual output generated")
else:
cost_factor = 5
logger.warning(
f"Job {job_id}: bilingual output skipped "
"(structure mismatch) — returning translated file"
)
except Exception as bi_err:
logger.warning(f"Job {job_id}: bilingual output failed: {bi_err}")
if user_id:
# Determine cost factor based on selected provider and model.
# _compute_cost_factor reads the model off the provider robustly
# (new-style providers store it in ``_model``, legacy in ``model``).
cost_factor = _compute_cost_factor(translation_provider, provider or "")
# Persist monthly usage counters in PostgreSQL (docs + pages)
pages = await asyncio.to_thread(
@@ -1537,7 +1831,7 @@ async def _run_translation_job(
tracker.set_completed(str(output_path))
# Record translation metric
duration = time.time() - time.mktime(datetime.fromisoformat(job["created_at"].replace("Z", "+00:00")).timetuple())
duration = _compute_duration_seconds(job.get("created_at", ""))
record_translation(provider=provider, file_type=file_extension or "unknown", duration=duration, status="success")
logger.info(f"Job {job_id}: Completed successfully")
@@ -1621,6 +1915,50 @@ async def _run_translation_job(
)
def _check_job_access(
job: dict, current_user, token: Optional[str]
) -> Optional[JSONResponse]:
"""Return an error response if the caller may not see this job, else None.
Jobs owned by a logged-in user require that same user. Anonymous jobs
require the secret per-job token returned when the job was created.
"""
job_user_id = job.get("user_id")
if job_user_id:
if not current_user:
return JSONResponse(
status_code=401,
content={
"error": "AUTH_REQUIRED",
"message": "Authentication is required to access this job.",
"details": {"job_id": job.get("id")},
},
)
if str(job_user_id) != str(current_user.id):
return JSONResponse(
status_code=403,
content={
"error": "ACCESS_DENIED",
"message": "You do not have access to this job.",
"details": {"job_id": job.get("id")},
},
)
return None
expected = job.get("access_token")
provided = token or ""
if not expected or not secrets.compare_digest(provided, expected):
return JSONResponse(
status_code=403,
content={
"error": "ACCESS_DENIED",
"message": "You do not have access to this job.",
"details": {"job_id": job.get("id"), "hint": "token_required"},
},
)
return None
@router_v1.get(
"/translations/{job_id}",
response_model=TranslationStatusResponse,
@@ -1631,6 +1969,7 @@ async def _run_translation_job(
)
async def get_translation_status(
job_id: str,
token: Optional[str] = None,
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
@@ -1680,6 +2019,10 @@ async def get_translation_status(
},
)
denied = _check_job_access(job, current_user, token)
if denied:
return denied
response_data = {
"id": job["id"],
"status": job["status"],
@@ -1689,6 +2032,7 @@ async def get_translation_status(
"source_lang": job.get("source_lang"),
"target_lang": job.get("target_lang"),
"created_at": job.get("created_at"),
"quality": job.get("quality"),
}
estimated_remaining = None
@@ -1768,6 +2112,7 @@ def _cleanup_files(input_path: Optional[str], output_path: Optional[str]) -> Non
)
async def download_translated_file(
job_id: str,
token: Optional[str] = None,
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
@@ -1819,16 +2164,9 @@ async def download_translated_file(
},
)
job_user_id = job.get("user_id")
if current_user and job_user_id and str(job_user_id) != str(current_user.id):
return JSONResponse(
status_code=403,
content={
"error": "ACCESS_DENIED",
"message": "You do not have access to this file.",
"details": {"job_id": job_id},
},
)
denied = _check_job_access(job, current_user, token)
if denied:
return denied
if job.get("status") != "completed":
return JSONResponse(