Files
office_translator/services/quality/length_checker.py
sepehr f403b2851d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m5s
feat(quality): add L0 quality layer (Track A1 + A2 of dev plan)
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.
2026-07-14 16:17:43 +02:00

126 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Length sanity check for the L0 quality layer.
A translation that's 10× longer or 10× shorter than the source is almost
certainly a hallucination or a truncation. We flag these as warnings
(not failures) so the caller can decide what to do.
Thresholds are tunable via env vars if needed, but the defaults work
well for prose documents. Tables and bullet lists will naturally have
shorter translations, so we keep the lower bound loose.
Special cases:
* If the source is mostly digits/punctuation, the translation can also
be short (e.g. "Price: 100$""100€") — skip the check.
* If the source is empty/very short, skip the check entirely.
* If the source contains an embedded URL or email, the translation may
legitimately shrink — skip the check.
"""
from __future__ import annotations
import re
from typing import Dict
# Default thresholds — generous enough to handle tables / short strings.
RATIO_MAX = 3.5
RATIO_MIN = 0.15
# Hard lower bound: a translation shorter than this is very suspect,
# UNLESS the source is also very short.
ABSOLUTE_MIN_LENGTH = 2
# If source is short (under this many chars), skip the ratio check entirely
# — short strings are too noisy to be useful for length analysis.
MIN_SOURCE_LENGTH_FOR_RATIO = 20
# Pattern to detect text that is mostly digits / numbers / simple symbols.
# E.g. "Price: 100€", "+33 6 12 34 56 78", "192.168.1.1".
_MOSTLY_NUMERIC_RE = re.compile(r"^[\d\s\W]*$", re.UNICODE)
_NUMERIC_RATIO_THRESHOLD = 0.5 # 50% of letters are digits
def check(source_text: str, translated_text: str) -> Dict:
"""
Returns a dict like:
{
"issue": None | "length_outlier" | "truncation_suspect",
"ratio": float,
"source_length": int,
"translated_length": int,
}
Never raises.
"""
if not source_text:
return {
"issue": None,
"ratio": None,
"source_length": 0,
"translated_length": len(translated_text or ""),
}
src_len = len(source_text.strip())
trans_len = len(translated_text.strip())
# Empty translation — always suspect.
if trans_len == 0:
return {
"issue": "truncation_suspect",
"ratio": 0.0,
"source_length": src_len,
"translated_length": trans_len,
}
# If source is mostly digits/numbers, the translation can also be short
# (e.g. "Price: 100" → "100"). Don't flag length in this case.
if _is_mostly_numeric(source_text):
return {
"issue": None,
"ratio": None,
"source_length": src_len,
"translated_length": trans_len,
"note": "skipped: numeric source",
}
# If source is very short, skip the ratio check.
if src_len < MIN_SOURCE_LENGTH_FOR_RATIO:
return {
"issue": None,
"ratio": None,
"source_length": src_len,
"translated_length": trans_len,
}
ratio = trans_len / src_len
if ratio > RATIO_MAX:
return {
"issue": "length_outlier",
"ratio": round(ratio, 2),
"source_length": src_len,
"translated_length": trans_len,
}
if ratio < RATIO_MIN:
return {
"issue": "truncation_suspect",
"ratio": round(ratio, 2),
"source_length": src_len,
"translated_length": trans_len,
}
return {
"issue": None,
"ratio": round(ratio, 2),
"source_length": src_len,
"translated_length": trans_len,
}
def _is_mostly_numeric(text: str) -> bool:
"""True if at least 50% of non-whitespace characters are digits."""
if not text:
return False
chars = [c for c in text if not c.isspace()]
if not chars:
return False
digit_count = sum(1 for c in chars if c.isdigit())
return (digit_count / len(chars)) >= _NUMERIC_RATIO_THRESHOLD