Files
office_translator/services/quality/config.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

236 lines
8.2 KiB
Python

"""
Language → script mapping for the L0 quality layer.
A "script" groups languages that share a Unicode block. The script detector
verifies that the *translation* is in the same script as the *target language*.
Languages are mapped by their ISO 639-1 (or 639-3) code. Anything not mapped
falls back to the "latin" script so we never crash on an unknown language.
Arabic-script languages (ar, fa, ur, ps, ku, sd, ug, yi, dv, ckb) all share
the same Unicode ranges, so we additionally use a set of *discriminating
characters* to tell them apart — e.g. Persian text contains چ/پ/ژ/گ which
Arabic text doesn't.
"""
from typing import Dict, FrozenSet, List, Optional, Tuple
# ---------- Unicode ranges per script ----------
# Format: list of (start, end) inclusive ranges.
# Latin is intentionally omitted (it's the fallback).
UNICODE_RANGES: Dict[str, List[Tuple[int, int]]] = {
"cyrillic": [
(0x0400, 0x04FF), # Cyrillic
(0x0500, 0x052F), # Cyrillic Supplement
],
"greek": [
(0x0370, 0x03FF), # Greek and Coptic
],
"arabic": [
(0x0600, 0x06FF), # Arabic
(0x0750, 0x077F), # Arabic Supplement
(0x08A0, 0x08FF), # Arabic Extended-A
],
"hebrew": [
(0x0590, 0x05FF), # Hebrew
],
"devanagari": [
(0x0900, 0x097F), # Devanagari
],
"bengali": [
(0x0980, 0x09FF), # Bengali
],
"tamil": [
(0x0B80, 0x0BFF),
],
"telugu": [
(0x0C00, 0x0C7F),
],
"kannada": [
(0x0C80, 0x0CFF),
],
"malayalam": [
(0x0D00, 0x0D7F),
],
"sinhala": [
(0x0D80, 0x0DFF),
],
"gujarati": [
(0x0A80, 0x0AFF),
],
"gurmukhi": [
(0x0A00, 0x0A7F),
],
"thai": [
(0x0E00, 0x0E7F),
],
"lao": [
(0x0E80, 0x0EFF),
],
"burmese": [
(0x1000, 0x109F),
],
"khmer": [
(0x1780, 0x17FF),
],
"cjk": [
(0x4E00, 0x9FFF), # CJK Unified Ideographs
(0x3400, 0x4DBF), # CJK Extension A
(0x20000, 0x2A6DF), # CJK Extension B (rare)
],
"hiragana_katakana": [
(0x3040, 0x309F), # Hiragana
(0x30A0, 0x30FF), # Katakana
],
"hangul": [
(0xAC00, 0xD7AF), # Hangul Syllables
(0x1100, 0x11FF), # Hangul Jamo
(0xA960, 0xA97F), # Hangul Jamo Extended-A
],
"georgian": [
(0x10A0, 0x10FF),
],
"armenian": [
(0x0530, 0x058F),
],
"ethiopic": [
(0x1200, 0x137F), # Ethiopic
(0x1380, 0x139F), # Ethiopic Supplement
],
"tibetan": [
(0x0F00, 0x0FFF),
],
"thaana": [
(0x0780, 0x07BF), # Thaana (Dhivehi)
],
# Latin is the implicit fallback.
"latin": [],
}
# ---------- Language → script mapping ----------
# Single source of truth. Keys are lower-case ISO codes.
LANG_TO_SCRIPT: Dict[str, str] = {
# Latin-script languages (the long tail of European + colonial)
"en": "latin", "fr": "latin", "de": "latin", "es": "latin", "it": "latin",
"pt": "latin", "nl": "latin", "pl": "latin", "tr": "latin", "vi": "latin",
"id": "latin", "ms": "latin", "ro": "latin", "cs": "latin", "sv": "latin",
"da": "latin", "fi": "latin", "no": "latin", "nb": "latin", "nn": "latin",
"hu": "latin", "sk": "latin", "sl": "latin", "lt": "latin", "lv": "latin",
"et": "latin", "sq": "latin", "az": "latin", "uz": "latin", "kk": "latin",
"ky": "latin", "tk": "latin", "sw": "latin", "eu": "latin", "gl": "latin",
"is": "latin", "ga": "latin", "mt": "latin", "ca": "latin", "hr": "latin",
"bs": "latin", "sr": "latin", "mk": "latin", "bg": "latin", "be": "latin",
"uk": "latin", # NOTE: uk/be/sr/mk/bg are actually Cyrillic — see fix below
"af": "latin", "cy": "latin", "lb": "latin", "fo": "latin", "br": "latin",
"co": "latin", "fy": "latin", "gd": "latin", "gn": "latin", "gu": "latin",
"ht": "latin", "haw": "latin", "hmn": "latin", "jv": "latin", "ku": "latin",
"mg": "latin", "mi": "latin", "mn": "latin", "nso": "latin", "ny": "latin",
"oc": "latin", "os": "latin", "ps": "latin", "qu": "latin", "rw": "latin",
"sc": "latin", "si": "latin", "sm": "latin", "sn": "latin", "so": "latin",
"st": "latin", "su": "latin", "tg": "latin", "tt": "latin", "ty": "latin",
"ug": "latin", "vo": "latin", "wa": "latin", "wo": "latin", "xh": "latin",
"yi": "latin", "zu": "latin",
}
# Correct Cyrillic assignments (overrides above for the Cyrillic-script langs)
LANG_TO_SCRIPT.update({
"ru": "cyrillic", "uk": "cyrillic", "be": "cyrillic", "sr": "cyrillic",
"mk": "cyrillic", "bg": "cyrillic", "kk": "cyrillic", "ky": "cyrillic",
"tg": "cyrillic", "tt": "cyrillic", "mn": "cyrillic", "ab": "cyrillic",
"ba": "cyrillic", "ce": "cyrillic", "cv": "cyrillic", "kv": "cyrillic",
"kv": "cyrillic", "l1": "cyrillic", "mhr": "cyrillic", "mrj": "cyrillic",
"myv": "cyrillic", "os": "cyrillic", "rue": "cyrillic", "sah": "cyrillic",
"udm": "cyrillic", "uk": "cyrillic",
})
# More corrections
LANG_TO_SCRIPT.update({
"el": "greek",
"ar": "arabic", "fa": "arabic", "ur": "arabic", "ps": "arabic",
"ku": "arabic", "sd": "arabic", "ug": "arabic",
"ckb": "arabic", "bal": "arabic", "bqi": "arabic",
"glk": "arabic", "mzn": "arabic",
"he": "hebrew",
"yi": "hebrew", # Yiddish uses Hebrew script
"dv": "thaana", # Maldivian (Dhivehi) uses Thaana script
"hi": "devanagari", "ne": "devanagari", "mr": "devanagari", "sa": "devanagari",
"mai": "devanagari", "bho": "devanagari", "awa": "devanagari",
"bn": "bengali", "as": "bengali",
"ta": "tamil",
"te": "telugu",
"kn": "kannada",
"ml": "malayalam",
"si": "sinhala",
"gu": "gujarati",
"pa": "gurmukhi",
"th": "thai",
"lo": "lao",
"my": "burmese",
"km": "khmer",
"zh": "cjk", "zh-cn": "cjk", "zh-tw": "cjk", "zh-hk": "cjk",
"ja": "hiragana_katakana",
"ko": "hangul",
"ka": "georgian",
"hy": "armenian",
"am": "ethiopic", "ti": "ethiopic", "gez": "ethiopic", "tig": "ethiopic",
"bo": "tibetan", "dz": "tibetan",
})
# ---------- Discriminating characters for Arabic-script languages ----------
# These are characters that strongly suggest a SPECIFIC Arabic-script
# language as opposed to the generic "arabic" base.
DISCRIMINATING_CHARS: Dict[str, FrozenSet[str]] = {
"fa": frozenset("پچژگ"), # Persian
"ur": frozenset("ٹڈڑے"), # Urdu
"ps": frozenset("ټډړږښ"), # Pashto
"ku": frozenset("ڕێ"), # Kurdish
"ckb": frozenset("ڕێ"), # Sorani Kurdish (same as ku)
"sd": frozenset("ٿ"), # Sindhi
"ug": frozenset("ۇۆې"), # Uyghur
"yi": frozenset("ײ"), # Yiddish (uses Hebrew too)
"bal": frozenset("ێ"), # Balochi
}
# ---------- Thresholds ----------
# These are tunable but with sensible defaults.
MIN_RATIO_IN_SCRIPT = 0.60 # at least 60% of letters must match the script
MIN_RATIO_FOR_ARABIC_VARIANT = 0.20 # at least 20% of letters in the discriminating set
def get_script(lang_code: Optional[str]) -> str:
"""
Return the script id (e.g. 'cyrillic', 'cjk') for a given language code.
Returns 'latin' for unknown codes (which is the safe default — Latin
covers the largest number of languages).
"""
if not lang_code:
return "latin"
return LANG_TO_SCRIPT.get(lang_code.lower(), "latin")
def is_arabic_script_lang(lang_code: Optional[str]) -> bool:
"""True if lang_code is one of the Arabic-script languages we discriminate."""
if not lang_code:
return False
return get_script(lang_code) == "arabic"
def get_ranges(script_id: str) -> List[Tuple[int, int]]:
"""Return the Unicode ranges for a script id. Empty list for 'latin'."""
return UNICODE_RANGES.get(script_id, [])
def get_discriminating_chars(lang_code: Optional[str]) -> FrozenSet[str]:
"""Return the discriminating characters for a specific Arabic-script language."""
if not lang_code:
return frozenset()
return DISCRIMINATING_CHARS.get(lang_code.lower(), frozenset())