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

199
services/translation_tm.py Normal file
View File

@@ -0,0 +1,199 @@
"""
Translation Memory (TM) — persistent reuse layer on top of the existing
Redis-backed cache (services/translation_cache.py).
Scope is PER USER (privacy: a customer's translations are never served to
another customer) and per translation context (custom prompt / glossary /
formality are hashed into the key so a glossary change invalidates old
matches). When Redis is not configured the cache transparently falls back
to a process-local LRU — still useful within a worker's lifetime.
"""
import hashlib
from typing import Dict, List, Optional, Tuple
from core.logging import get_logger
logger = get_logger(__name__)
class TMScope:
"""Identity/context of a translation job for TM keying."""
__slots__ = ("user_id", "context_hash")
def __init__(self, user_id: Optional[str], context_hash: Optional[str]):
self.user_id = user_id
self.context_hash = context_hash
@classmethod
def from_prompt(cls, user_id: Optional[str], prompt: Optional[str]) -> "TMScope":
"""Build a scope; the prompt (glossary + tone + formality merged)
is hashed so any directive change produces different TM entries."""
context_hash = None
if prompt:
context_hash = hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:16]
return cls(user_id=user_id, context_hash=context_hash)
def _cache():
from services.translation_cache import get_cache
return get_cache()
def lookup_tm(
texts: List[str],
target_language: str,
source_language: str,
provider_name: str,
scope: Optional[TMScope],
) -> Tuple[Dict[int, str], List[int]]:
"""Return ({index: cached_translation}, [indices not in TM]).
No-op (all misses) when the scope has no user_id — anonymous jobs are
never cached or served from another user's entries.
"""
if not scope or not scope.user_id:
return {}, list(range(len(texts)))
try:
cache = _cache()
except Exception as e:
logger.warning("tm_init_failed", error=str(e))
return {}, list(range(len(texts)))
hits: Dict[int, str] = {}
misses: List[int] = []
for i, text in enumerate(texts):
if not text or not text.strip():
misses.append(i)
continue
cached = cache.get(
text,
target_language,
source_language,
provider_name,
user_id=scope.user_id,
custom_prompt_hash=scope.context_hash,
)
if cached is not None and cached.strip():
hits[i] = cached
else:
misses.append(i)
if hits:
logger.info("tm_lookup_hits", hits=len(hits), misses=len(misses))
return hits, misses
def store_tm(
texts: List[str],
translations: List[str],
target_language: str,
source_language: str,
provider_name: str,
scope: Optional[TMScope],
) -> int:
"""Store fresh translations in the TM. Returns the number stored."""
if not scope or not scope.user_id:
return 0
try:
cache = _cache()
except Exception as e:
logger.warning("tm_init_failed", error=str(e))
return 0
stored = 0
for text, translation in zip(texts, translations):
if not text or not translation:
continue
# Never store identity entries — they would poison future lookups
# (an unchanged text is not a translation).
if translation.strip() == text.strip():
continue
cache.set(
text,
target_language,
source_language,
provider_name,
translation,
user_id=scope.user_id,
custom_prompt_hash=scope.context_hash,
)
stored += 1
if stored:
logger.info("tm_stored", entries=stored)
return stored
def tm_stats() -> Dict:
"""Backend stats for observability."""
try:
return _cache().stats()
except Exception:
return {}
def translate_with_tm(
texts: List[str],
target_language: str,
source_language: str,
provider_name: str,
scope: "TMScope | None",
translate_fn,
) -> List[str]:
"""Translate a batch with TM reuse: hits come from the cache, misses go
through ``translate_fn`` (which receives ONLY the missed texts, in
order) and fresh results are stored back. Falls back to a plain
``translate_fn(texts)`` call when the TM is unavailable.
"""
if not texts:
return []
tm_hits, miss_indices = lookup_tm(
texts, target_language, source_language, provider_name, scope
)
miss_texts = [texts[i] for i in miss_indices]
if not miss_texts:
return [tm_hits.get(i, texts[i]) for i in range(len(texts))]
try:
translated_misses = translate_fn(miss_texts)
except Exception:
if tm_hits:
# Provider failed but we have partial TM hits — keep them and
# leave the misses untouched rather than dropping everything.
logger.warning("tm_translate_fn_failed_partial_hits", hits=len(tm_hits))
translated_misses = None
else:
raise
if translated_misses is None:
return [
tm_hits.get(i, texts[i]) for i in range(len(texts))
]
store_tm(
miss_texts,
translated_misses,
target_language,
source_language,
provider_name,
scope,
)
merged: List[str] = []
miss_pos = 0
for i in range(len(texts)):
if i in tm_hits:
merged.append(tm_hits[i])
else:
merged.append(
translated_misses[miss_pos]
if miss_pos < len(translated_misses)
else texts[i]
)
miss_pos += 1
return merged