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:
@@ -107,6 +107,7 @@ class ExcelTranslator:
|
||||
self._provider = provider
|
||||
self.formula_pattern = re.compile(r"=.*")
|
||||
self._custom_prompt: Optional[str] = None
|
||||
self._tm_scope = None # set via set_tm_scope (per-user translation memory)
|
||||
self._translation_stats = {"attempted": 0, "changed": 0}
|
||||
|
||||
def set_provider(self, provider: TranslationProvider) -> None:
|
||||
@@ -116,6 +117,14 @@ class ExcelTranslator:
|
||||
def set_custom_prompt(self, prompt: Optional[str]) -> None:
|
||||
"""Set custom system prompt for LLM providers."""
|
||||
self._custom_prompt = prompt
|
||||
def set_tm_scope(self, user_id, prompt=None) -> None:
|
||||
"""Enable the per-user translation memory for this job."""
|
||||
from services.translation_tm import TMScope
|
||||
|
||||
self._tm_scope = TMScope.from_prompt(
|
||||
user_id, prompt or getattr(self, "_custom_prompt", None)
|
||||
)
|
||||
|
||||
|
||||
def translate_file(
|
||||
self,
|
||||
@@ -318,6 +327,13 @@ class ExcelTranslator:
|
||||
new_name=new_name,
|
||||
)
|
||||
|
||||
# openpyxl does NOT rewrite references on rename: cell
|
||||
# formulas, defined names and chart refs pointing at the old
|
||||
# sheet would break (#REF!/#NAME?). Charts are handled later
|
||||
# via ZIP re-injection; fix cells + defined names here.
|
||||
if sheet_name_mapping:
|
||||
self._rewrite_sheet_refs_in_workbook(workbook, sheet_name_mapping)
|
||||
|
||||
if translate_images:
|
||||
_log_info("excel_image_translation_start", sheets=len(workbook.sheetnames))
|
||||
for sheet_name in workbook.sheetnames:
|
||||
@@ -427,12 +443,30 @@ class ExcelTranslator:
|
||||
non_empty = [t for t in texts if t and t.strip()]
|
||||
self._translation_stats["attempted"] += len(non_empty)
|
||||
|
||||
from services.translation_tm import translate_with_tm
|
||||
|
||||
provider_name = (
|
||||
self._provider.get_name() if hasattr(self._provider, "get_name")
|
||||
else type(self._provider).__name__
|
||||
) if self._provider is not None else "legacy"
|
||||
|
||||
if self._provider is not None:
|
||||
translated = self._translate_with_provider(
|
||||
texts, target_language, source_language
|
||||
)
|
||||
def _do_translate(miss_texts):
|
||||
return self._translate_with_provider(
|
||||
miss_texts, target_language, source_language
|
||||
)
|
||||
else:
|
||||
translated = self._translate_with_legacy(texts, target_language, source_language)
|
||||
def _do_translate(miss_texts):
|
||||
return self._translate_with_legacy(
|
||||
miss_texts, target_language, source_language
|
||||
)
|
||||
|
||||
# Translation memory: reuse this user's previous translations
|
||||
# (identical context/prompt) before hitting the provider.
|
||||
translated = translate_with_tm(
|
||||
texts, target_language, source_language,
|
||||
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
||||
)
|
||||
|
||||
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
||||
self._translation_stats["changed"] += changed
|
||||
@@ -931,6 +965,140 @@ class ExcelTranslator:
|
||||
rewritten += 1
|
||||
return rewritten
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_sheet_refs_in_formula(
|
||||
formula: str, sheet_name_mapping: Dict[str, str]
|
||||
) -> str:
|
||||
"""Rewrite every sheet reference inside a formula string.
|
||||
|
||||
Handles quoted ('Ventes 2026'!), unquoted (Ventes!) and 3D refs
|
||||
(Sheet1!A1:Sheet2!B2 — each end is rewritten independently).
|
||||
Longest names are replaced first so a name that is a prefix of
|
||||
another is not corrupted.
|
||||
"""
|
||||
if not formula or not sheet_name_mapping or "!" not in formula:
|
||||
return formula
|
||||
|
||||
result = formula
|
||||
# longest first to avoid partial-name collisions
|
||||
for old in sorted(sheet_name_mapping, key=len, reverse=True):
|
||||
new = sheet_name_mapping[old]
|
||||
if new == old:
|
||||
continue
|
||||
new_quoted = ExcelTranslator._quote_sheet_name_for_ref(new)
|
||||
# Quoted form: inner apostrophes are escaped as ''
|
||||
escaped = old.replace("'", "''")
|
||||
result = re.sub(
|
||||
rf"'{re.escape(escaped)}'!",
|
||||
new_quoted + "!",
|
||||
result,
|
||||
)
|
||||
# Unquoted form: only when the old name needs no quotes; guard
|
||||
# against matching the tail of a longer name.
|
||||
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]*", old):
|
||||
result = re.sub(
|
||||
rf"(?<![A-Za-z0-9_.']){re.escape(old)}!",
|
||||
new_quoted + "!",
|
||||
result,
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _rewrite_sheet_refs_in_workbook(
|
||||
cls, workbook, sheet_name_mapping: Dict[str, str]
|
||||
) -> None:
|
||||
"""After a sheet rename, fix every reference that still points at the
|
||||
old names: cell formulas, defined names, data validations and
|
||||
conditional-formatting rules. openpyxl does none of this on rename.
|
||||
|
||||
Best-effort: individual failures are logged and skipped — a broken
|
||||
rename must never lose the whole file.
|
||||
"""
|
||||
if not sheet_name_mapping:
|
||||
return
|
||||
|
||||
cells_fixed = names_fixed = validations_fixed = cf_fixed = 0
|
||||
|
||||
try:
|
||||
for worksheet in workbook.worksheets:
|
||||
# 1) Cell formulas
|
||||
for row in worksheet.iter_rows():
|
||||
for cell in row:
|
||||
value = cell.value
|
||||
if isinstance(value, str) and value.startswith("=") and "!" in value:
|
||||
updated = cls._rewrite_sheet_refs_in_formula(
|
||||
value, sheet_name_mapping
|
||||
)
|
||||
if updated != value:
|
||||
cell.value = updated
|
||||
cells_fixed += 1
|
||||
|
||||
# 2) Data validations (list sources / custom formulas)
|
||||
try:
|
||||
for dv in worksheet.data_validations.dataValidation:
|
||||
for attr in ("formula1", "formula2"):
|
||||
fx = getattr(dv, attr, None)
|
||||
if isinstance(fx, str) and "!" in fx:
|
||||
updated = cls._rewrite_sheet_refs_in_formula(
|
||||
fx, sheet_name_mapping
|
||||
)
|
||||
if updated != fx:
|
||||
setattr(dv, attr, updated)
|
||||
validations_fixed += 1
|
||||
except Exception as e:
|
||||
_log_warning(
|
||||
"excel_sheet_refs_validations_failed",
|
||||
sheet=worksheet.title,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# 3) Conditional formatting rules
|
||||
try:
|
||||
for cf in worksheet.conditional_formatting:
|
||||
for rule in cf.rules:
|
||||
formulas = getattr(rule, "formula", None) or []
|
||||
for idx, fx in enumerate(formulas):
|
||||
if isinstance(fx, str) and "!" in fx:
|
||||
updated = cls._rewrite_sheet_refs_in_formula(
|
||||
fx, sheet_name_mapping
|
||||
)
|
||||
if updated != fx:
|
||||
formulas[idx] = updated
|
||||
cf_fixed += 1
|
||||
except Exception as e:
|
||||
_log_warning(
|
||||
"excel_sheet_refs_condfmt_failed",
|
||||
sheet=worksheet.title,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# 4) Workbook-level defined names
|
||||
try:
|
||||
for name in list(workbook.defined_names.values()):
|
||||
attr_text = getattr(name, "attr_text", None)
|
||||
if isinstance(attr_text, str) and "!" in attr_text:
|
||||
updated = cls._rewrite_sheet_refs_in_formula(
|
||||
attr_text, sheet_name_mapping
|
||||
)
|
||||
if updated != attr_text:
|
||||
name.attr_text = updated
|
||||
names_fixed += 1
|
||||
except Exception as e:
|
||||
_log_warning("excel_sheet_refs_names_failed", error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
_log_error("excel_sheet_refs_rewrite_failed", error=str(e))
|
||||
return
|
||||
|
||||
if cells_fixed or names_fixed or validations_fixed or cf_fixed:
|
||||
_log_info(
|
||||
"excel_sheet_refs_rewritten",
|
||||
cells=cells_fixed,
|
||||
defined_names=names_fixed,
|
||||
validations=validations_fixed,
|
||||
conditional_formats=cf_fixed,
|
||||
)
|
||||
|
||||
def _translate_images(self, worksheet: Worksheet, target_language: str) -> None:
|
||||
"""
|
||||
Translate text in images using vision model.
|
||||
|
||||
Reference in New Issue
Block a user