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)
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""New feature tests: formality/regional prompts, TM, bilingual output,
|
|
QA report, OpenAI JSON batching, font hints."""
|
|
|
|
import pytest
|
|
from docx import Document
|
|
from openpyxl import Workbook
|
|
|
|
from services.glossary_service import build_full_prompt
|
|
from services.quality.qa_report import (
|
|
_number_fidelity,
|
|
_untranslated_ratio,
|
|
run_qa_report,
|
|
)
|
|
from services.translation_tm import TMScope, translate_with_tm
|
|
from services.translation_cache import reset_cache_for_tests
|
|
from translators.bilingual import make_bilingual_docx
|
|
from translators.word_translator import WordTranslator, _font_hints_for_target
|
|
|
|
|
|
# ===========================================================================
|
|
# Formality + regional variant in prompts
|
|
# ===========================================================================
|
|
class TestFormalityPrompt:
|
|
def test_formal_adds_tone_directive(self):
|
|
prompt = build_full_prompt(None, None, "fr", "en", formality="formal")
|
|
assert "TONE:" in prompt and "formal" in prompt
|
|
|
|
def test_informal_adds_tone_directive(self):
|
|
prompt = build_full_prompt(None, None, "fr", "en", formality="informal")
|
|
assert "TONE:" in prompt and "informal" in prompt
|
|
|
|
def test_no_formality_no_tone(self):
|
|
assert "TONE:" not in build_full_prompt(None, None, "fr", "en")
|
|
|
|
def test_regional_variant_auto(self):
|
|
prompt = build_full_prompt(None, None, "fr", "pt-BR")
|
|
assert "REGIONAL VARIANT" in prompt
|
|
assert "Portuguese" in prompt
|
|
|
|
def test_no_region_no_variant(self):
|
|
assert "REGIONAL VARIANT" not in build_full_prompt(None, None, "fr", "en")
|
|
|
|
|
|
# ===========================================================================
|
|
# Translation memory (per-user scoping)
|
|
# ===========================================================================
|
|
class TestTranslationMemory:
|
|
@pytest.fixture(autouse=True)
|
|
def clean_cache(self):
|
|
reset_cache_for_tests()
|
|
yield
|
|
reset_cache_for_tests()
|
|
|
|
def test_no_scope_passthrough(self):
|
|
calls = []
|
|
|
|
def fake_translate(texts):
|
|
calls.extend(texts)
|
|
return [t.upper() for t in texts]
|
|
|
|
out = translate_with_tm(
|
|
["a", "b"], "en", "fr", "fake", None, fake_translate
|
|
)
|
|
assert out == ["A", "B"]
|
|
assert calls == ["a", "b"]
|
|
|
|
def test_reuse_on_second_call_same_user(self):
|
|
from services.translation_cache import get_cache
|
|
|
|
get_cache() # init LRU backend
|
|
|
|
calls = []
|
|
|
|
def fake_translate(texts):
|
|
calls.extend(texts)
|
|
return [f"T:{t}" for t in texts]
|
|
|
|
scope = TMScope.from_prompt("user-1", "ctx")
|
|
first = translate_with_tm(["hello", "world"], "en", "fr", "fake", scope, fake_translate)
|
|
assert first == ["T:hello", "T:world"]
|
|
assert len(calls) == 2
|
|
|
|
second = translate_with_tm(["hello", "world"], "en", "fr", "fake", scope, fake_translate)
|
|
assert second == ["T:hello", "T:world"]
|
|
# Everything came from the TM — no new provider calls
|
|
assert len(calls) == 2
|
|
|
|
def test_users_are_isolated(self):
|
|
from services.translation_cache import get_cache
|
|
|
|
get_cache()
|
|
out = translate_with_tm(
|
|
["x"], "en", "fr", "fake",
|
|
TMScope.from_prompt("user-A", None), lambda ts: [f"A:{ts[0]}"],
|
|
)
|
|
assert out == ["A:x"]
|
|
out_b = translate_with_tm(
|
|
["x"], "en", "fr", "fake",
|
|
TMScope.from_prompt("user-B", None), lambda ts: [f"B:{ts[0]}"],
|
|
)
|
|
assert out_b == ["B:x"]
|
|
|
|
def test_identity_translations_not_stored(self):
|
|
from services.translation_cache import get_cache
|
|
|
|
get_cache()
|
|
scope = TMScope.from_prompt("user-1", None)
|
|
# identity provider: returns input unchanged (a provider failure)
|
|
translate_with_tm(["same"], "en", "fr", "fake", scope, lambda ts: ts)
|
|
out = translate_with_tm(
|
|
["same"], "en", "fr", "fake", scope, lambda ts: ["CALLED"]
|
|
)
|
|
# Not poisoned by the identity entry: the provider was consulted
|
|
assert out == ["CALLED"]
|
|
|
|
def test_different_prompt_context_misses(self):
|
|
from services.translation_cache import get_cache
|
|
|
|
get_cache()
|
|
s1 = TMScope.from_prompt("u", "prompt-A")
|
|
s2 = TMScope.from_prompt("u", "prompt-B")
|
|
translate_with_tm(["k"], "en", "fr", "fake", s1, lambda ts: ["v1"])
|
|
out = translate_with_tm(["k"], "en", "fr", "fake", s2, lambda ts: ["v2"])
|
|
assert out == ["v2"]
|
|
|
|
|
|
# ===========================================================================
|
|
# Bilingual output
|
|
# ===========================================================================
|
|
class TestBilingual:
|
|
def test_source_interleaved_above_translation(self, tmp_path):
|
|
src = Document()
|
|
src.add_paragraph("Bonjour le monde")
|
|
src.add_paragraph("")
|
|
src.add_paragraph("Deuxième paragraphe")
|
|
src_in = tmp_path / "src.docx"
|
|
src.save(str(src_in))
|
|
|
|
tr = Document()
|
|
tr.add_paragraph("Hello world")
|
|
tr.add_paragraph("")
|
|
tr.add_paragraph("Second paragraph")
|
|
tr_out = tmp_path / "tr.docx"
|
|
tr.save(str(tr_out))
|
|
|
|
result = make_bilingual_docx(src_in, tr_out, tmp_path / "bi.docx")
|
|
assert result is not None
|
|
|
|
doc = Document(str(result))
|
|
texts = [p.text for p in doc.paragraphs]
|
|
# Source paragraph precedes its translation
|
|
assert texts[0] == "Bonjour le monde"
|
|
assert texts[1] == "Hello world"
|
|
assert texts[3] == "Deuxième paragraphe"
|
|
assert texts[4] == "Second paragraph"
|
|
|
|
def test_structure_mismatch_returns_none(self, tmp_path):
|
|
a = Document(); a.add_paragraph("x"); a.save(str(tmp_path / "a.docx"))
|
|
b = Document(); b.add_paragraph("x"); b.add_paragraph("y")
|
|
b.save(str(tmp_path / "b.docx"))
|
|
assert make_bilingual_docx(
|
|
tmp_path / "a.docx", tmp_path / "b.docx", tmp_path / "bi.docx"
|
|
) is None
|
|
|
|
|
|
# ===========================================================================
|
|
# QA report
|
|
# ===========================================================================
|
|
class TestQAReport:
|
|
def test_number_fidelity_full(self):
|
|
r = _number_fidelity("Prix: 12,50 € sur 3 pages", "Price: 12.50 € on 3 pages")
|
|
assert r["fidelity"] >= 0.9
|
|
|
|
def test_number_fidelity_missing(self):
|
|
r = _number_fidelity("Ref 123 and 456", "Ref 123 only")
|
|
assert r["fidelity"] < 1.0
|
|
|
|
def test_untranslated_ratio_high_when_identity(self):
|
|
identity_source = (
|
|
"This document contains substantial english text about pricing, "
|
|
"delivery schedules and quarterly reporting obligations for the "
|
|
"regional sales team and their managers."
|
|
)
|
|
assert _untranslated_ratio(identity_source, identity_source) > 0.8
|
|
|
|
def test_untranslated_ratio_low_when_translated(self):
|
|
assert _untranslated_ratio(
|
|
"Ce document contient beaucoup de mots différents sur la facturation, "
|
|
"les échéances de livraison et les obligations trimestrielles.",
|
|
"This document holds many different words about invoicing, "
|
|
"delivery deadlines and quarterly obligations.",
|
|
) < 0.35
|
|
|
|
def test_run_qa_report_on_docx(self, tmp_path):
|
|
src = Document()
|
|
src.add_paragraph("Le total est de 42 euros pour la livraison.")
|
|
src.add_paragraph("Merci de votre confiance renouvelée.")
|
|
src_in = tmp_path / "in.docx"
|
|
src.save(str(src_in))
|
|
|
|
out_doc = Document()
|
|
out_doc.add_paragraph("The total is 42 euros for the delivery.")
|
|
out_doc.add_paragraph("Thank you for your renewed trust.")
|
|
out_p = tmp_path / "out.docx"
|
|
out_doc.save(str(out_p))
|
|
|
|
report = run_qa_report(src_in, out_p, "en", ".docx")
|
|
assert report is not None
|
|
assert report["score"] >= 80
|
|
assert report["numbers"]["fidelity"] == 1.0
|
|
|
|
def test_run_qa_report_scores_untranslated_low(self, tmp_path):
|
|
long_fr = (
|
|
"Ce paragraphe contient suffisamment de mots différents pour "
|
|
"satisfaire l'heuristique du rapport qualité automatisé, avec "
|
|
"des notions de facturation, livraison et obligations."
|
|
)
|
|
src = Document()
|
|
src.add_paragraph(long_fr)
|
|
src_in = tmp_path / "in.docx"
|
|
src.save(str(src_in))
|
|
import shutil
|
|
|
|
out_p = tmp_path / "out.docx"
|
|
shutil.copy(str(src_in), str(out_p)) # "translation" = original
|
|
|
|
report = run_qa_report(src_in, out_p, "en", ".docx")
|
|
assert report is not None
|
|
assert report["score"] < 50
|
|
assert report["untranslated_ratio"] > 0.5
|
|
|
|
|
|
# ===========================================================================
|
|
# OpenAI JSON batching
|
|
# ===========================================================================
|
|
class TestOpenAIBatch:
|
|
def _provider(self):
|
|
from services.providers.openai_provider import OpenAITranslationProvider
|
|
|
|
return OpenAITranslationProvider(api_key="test-key", model="gpt-test")
|
|
|
|
def _requests(self, texts):
|
|
from services.providers.schemas import TranslationRequest
|
|
|
|
return [TranslationRequest(text=t, target_language="fr") for t in texts]
|
|
|
|
def test_batch_single_request_success(self, monkeypatch):
|
|
provider = self._provider()
|
|
seen = {}
|
|
|
|
def fake_api(text, system_prompt):
|
|
import json
|
|
|
|
seen["text"] = text
|
|
items = json.loads(text)
|
|
reply = json.dumps(
|
|
[{"id": it["id"], "translation": f"FR:{it['text']}"} for it in items]
|
|
)
|
|
return reply, {}
|
|
|
|
monkeypatch.setattr(provider, "_make_api_request", fake_api)
|
|
out = provider.translate_batch(self._requests(["one", "two", "three"]))
|
|
assert [r.translated_text for r in out] == [
|
|
"FR:one", "FR:two", "FR:three",
|
|
]
|
|
# One API call for the whole batch
|
|
assert isinstance(seen.get("text"), str) and '"id"' in seen["text"]
|
|
|
|
def test_batch_falls_back_on_bad_json(self, monkeypatch):
|
|
provider = self._provider()
|
|
calls = {"batch": 0, "single": 0}
|
|
|
|
def fake_api(text, system_prompt):
|
|
if text.startswith("["):
|
|
calls["batch"] += 1
|
|
return "not valid json at all", {}
|
|
calls["single"] += 1
|
|
return f"FR:{text}", {}
|
|
|
|
monkeypatch.setattr(provider, "_make_api_request", fake_api)
|
|
out = provider.translate_batch(self._requests(["alpha", "beta"]))
|
|
assert [r.translated_text for r in out] == ["FR:alpha", "FR:beta"]
|
|
assert calls["batch"] == 1
|
|
assert calls["single"] == 2
|
|
|
|
def test_batch_falls_back_on_wrong_length(self, monkeypatch):
|
|
import json
|
|
|
|
provider = self._provider()
|
|
|
|
def fake_api(text, system_prompt):
|
|
return json.dumps([{"id": 0, "translation": "only one"}]), {}
|
|
|
|
monkeypatch.setattr(provider, "_make_api_request", fake_api)
|
|
|
|
def fail_single(req):
|
|
from services.providers.schemas import TranslationResponse
|
|
|
|
return TranslationResponse(
|
|
translated_text=f"S:{req.text}", provider_name="openai"
|
|
)
|
|
|
|
monkeypatch.setattr(provider, "translate_text", fail_single)
|
|
out = provider.translate_batch(self._requests(["a", "b"]))
|
|
assert [r.translated_text for r in out] == ["S:a", "S:b"]
|
|
|
|
|
|
# ===========================================================================
|
|
# CJK font hints (Word)
|
|
# ===========================================================================
|
|
class TestFontHints:
|
|
def test_hint_mapping(self):
|
|
assert _font_hints_for_target("zh-CN")[0] == "SimSun"
|
|
assert _font_hints_for_target("ja")[0] == "Yu Mincho"
|
|
assert _font_hints_for_target("ar")[1] == "Arial"
|
|
assert _font_hints_for_target("en") == (None, None)
|
|
|
|
def test_applied_on_translate(self, tmp_path):
|
|
class _CJK:
|
|
def get_name(self):
|
|
return "mock"
|
|
|
|
def is_available(self):
|
|
return True
|
|
|
|
def translate_batch(self, texts, target_language, source_language="auto"):
|
|
return [f"译:{t}" for t in texts]
|
|
|
|
from docx.oxml.ns import qn as _qn
|
|
|
|
doc = Document()
|
|
doc.add_paragraph("Hello")
|
|
src = tmp_path / "in.docx"
|
|
doc.save(str(src))
|
|
|
|
t = WordTranslator(provider=_CJK())
|
|
out = tmp_path / "out.docx"
|
|
t.translate_file(src, out, "zh-CN", "en")
|
|
|
|
result = Document(str(out))
|
|
para = result.paragraphs[0]
|
|
rFonts = para.runs[0]._r.find(_qn("w:rPr")).find(_qn("w:rFonts"))
|
|
assert rFonts is not None
|
|
assert rFonts.get(_qn("w:eastAsia")) == "SimSun"
|