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)
200 lines
5.7 KiB
Python
200 lines
5.7 KiB
Python
"""
|
|
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
|