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
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:
199
services/translation_tm.py
Normal file
199
services/translation_tm.py
Normal 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
|
||||
Reference in New Issue
Block a user