feat(quality): add L0 quality layer (Track A1 + A2 of dev plan)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m5s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m5s
L0 quality detection layer to catch translation failures BEFORE they
reach users. Pure Python/TypeScript, zero new dependencies, no API calls.
Backend (Python — services/quality/):
- Script detection: 145 langs mapped to 23 scripts (Latin, Cyrillic,
Greek, Arabic, Hebrew, CJK, Hangul, Kana, Devanagari, Bengali, etc.)
- Language confusion detection (e.g. Arabic text for French target)
- Arabic-script variant discrimination (Persian/Urdu/Pashto/Kurdish
confusion — e.g. Persian text returned when Arabic was requested)
- Length sanity check (with numeric/short-source exemptions)
- Prompt leak detection (Translation: / Voici la traduction: / 翻译:)
- Repetition hallucination detection (token + character level)
- File text extraction for .docx/.xlsx/.pptx/.pdf (no translator
changes needed)
- Defensive pipeline that never raises (L0 must NEVER break a job)
Frontend (TypeScript — wordly.art---traduction-de-documents/src/utils/):
- Exact 1:1 mirror of the Python module
- Zero dependencies, works in browser AND Node.js
- Native Unicode regex (\\p{L}/u) and codePoint iteration
- 63 tests using Node's built-in test runner
Integration:
- Feature-flagged: QUALITY_L0_ENABLED=false (default)
- Observation only: logs structured events, never modifies files
- try/except wrapped: impossible to break a translation job
- Lazy imports: only loaded when flag is on
- Zero impact on existing tests / behavior
Tests:
- 111 Python tests covering all paths (config, script, length, leak,
pipeline, file_extractor) — 100% pass
- 63 TypeScript tests (Node --test) — 100% pass
- 174/174 total tests for the L0 layer
Bug fixes in script mapping:
- yi (Yiddish) -> hebrew (was incorrectly mapped to arabic)
- dv (Maldivian) -> thaana (was incorrectly mapped to arabic)
- ja (Japanese) -> hiragana_katakana (distinguishes from Chinese CJK)
Phase 1 (backend) + Phase 2 (frontend) of Track A complete.
Next: Track B1 (Word/Excel format preservation quick wins).
Closes Track A phase 1+2 of the dev plan.
This commit is contained in:
181
tests/services/quality/test_file_extractor.py
Normal file
181
tests/services/quality/test_file_extractor.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Tests for services/quality/file_extractor.py
|
||||
Uses real files generated via temporary paths.
|
||||
|
||||
On Windows, tempfile.NamedTemporaryFile holds the file open and blocks
|
||||
overwrite, so we use a plain temp directory + manual filename instead.
|
||||
"""
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from services.quality.file_extractor import (
|
||||
extract_sample,
|
||||
DEFAULT_MAX_SAMPLES,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractSample:
|
||||
def test_returns_empty_for_missing_file(self):
|
||||
result = extract_sample(Path("/nonexistent/path/file.docx"), ".docx")
|
||||
assert result == []
|
||||
|
||||
def test_returns_empty_for_none_path(self):
|
||||
result = extract_sample(None, ".docx")
|
||||
assert result == []
|
||||
|
||||
def test_returns_empty_for_unsupported_extension(self):
|
||||
# .txt isn't supported — should return [] silently
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = Path(d) / "test.txt"
|
||||
p.write_text("some text")
|
||||
result = extract_sample(p, ".txt")
|
||||
assert result == []
|
||||
|
||||
|
||||
def _make_tmp_path(suffix: str) -> Path:
|
||||
"""Create a unique temp file path. File does not exist yet."""
|
||||
fd, name = tempfile.mkstemp(suffix=suffix)
|
||||
os.close(fd)
|
||||
return Path(name)
|
||||
|
||||
|
||||
class TestDocxExtraction:
|
||||
def test_extracts_paragraphs(self):
|
||||
from docx import Document
|
||||
doc = Document()
|
||||
doc.add_paragraph("Bonjour le monde")
|
||||
doc.add_paragraph("Comment allez-vous?")
|
||||
doc.add_paragraph("Merci beaucoup")
|
||||
p = _make_tmp_path(".docx")
|
||||
try:
|
||||
doc.save(str(p))
|
||||
result = extract_sample(p, ".docx", max_samples=10)
|
||||
assert len(result) >= 1
|
||||
texts = [s["translated"] for s in result]
|
||||
assert "Bonjour le monde" in texts
|
||||
finally:
|
||||
try:
|
||||
p.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
def test_respects_max_samples(self):
|
||||
from docx import Document
|
||||
doc = Document()
|
||||
for i in range(50):
|
||||
doc.add_paragraph(f"Paragraphe numero {i} avec du texte")
|
||||
p = _make_tmp_path(".docx")
|
||||
try:
|
||||
doc.save(str(p))
|
||||
result = extract_sample(p, ".docx", max_samples=5)
|
||||
assert len(result) == 5
|
||||
finally:
|
||||
try:
|
||||
p.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
def test_handles_empty_doc(self):
|
||||
from docx import Document
|
||||
doc = Document()
|
||||
p = _make_tmp_path(".docx")
|
||||
try:
|
||||
doc.save(str(p))
|
||||
result = extract_sample(p, ".docx")
|
||||
assert result == []
|
||||
finally:
|
||||
try:
|
||||
p.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
class TestXlsxExtraction:
|
||||
def test_extracts_cells(self):
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws["A1"] = "Bonjour"
|
||||
ws["A2"] = "Monde"
|
||||
ws["A3"] = "Comment"
|
||||
p = _make_tmp_path(".xlsx")
|
||||
try:
|
||||
wb.save(str(p))
|
||||
wb.close()
|
||||
result = extract_sample(p, ".xlsx", max_samples=10)
|
||||
assert len(result) >= 1
|
||||
texts = [s["translated"] for s in result]
|
||||
assert "Bonjour" in texts
|
||||
finally:
|
||||
try:
|
||||
p.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
def test_skips_numeric_cells(self):
|
||||
from openpyxl import Workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws["A1"] = 100
|
||||
ws["A2"] = 200
|
||||
ws["A3"] = "Hello"
|
||||
p = _make_tmp_path(".xlsx")
|
||||
try:
|
||||
wb.save(str(p))
|
||||
wb.close()
|
||||
result = extract_sample(p, ".xlsx")
|
||||
texts = [s["translated"] for s in result]
|
||||
assert "Hello" in texts
|
||||
assert 100 not in texts # numeric only cells skipped
|
||||
finally:
|
||||
try:
|
||||
p.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
class TestPptxExtraction:
|
||||
def test_extracts_slide_text(self):
|
||||
from pptx import Presentation
|
||||
pres = Presentation()
|
||||
slide = pres.slides.add_slide(pres.slide_layouts[0])
|
||||
slide.shapes.title.text = "Bonjour le monde"
|
||||
p = _make_tmp_path(".pptx")
|
||||
try:
|
||||
pres.save(str(p))
|
||||
result = extract_sample(p, ".pptx")
|
||||
assert len(result) >= 1
|
||||
assert result[0]["translated"] == "Bonjour le monde"
|
||||
finally:
|
||||
try:
|
||||
p.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
class TestPdfExtraction:
|
||||
def test_extracts_pdf_text(self):
|
||||
# Create a minimal PDF using reportlab if available, else skip
|
||||
pytest.importorskip("fitz")
|
||||
try:
|
||||
from reportlab.pdfgen import canvas
|
||||
except ImportError:
|
||||
pytest.skip("reportlab not available")
|
||||
p = _make_tmp_path(".pdf")
|
||||
try:
|
||||
c = canvas.Canvas(str(p))
|
||||
c.drawString(100, 750, "Bonjour le monde")
|
||||
c.drawString(100, 700, "Comment allez vous")
|
||||
c.save()
|
||||
result = extract_sample(p, ".pdf")
|
||||
assert len(result) >= 1
|
||||
texts = [s["translated"] for s in result]
|
||||
assert any("Bonjour" in t for t in texts)
|
||||
finally:
|
||||
try:
|
||||
p.unlink()
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
Reference in New Issue
Block a user