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:
@@ -96,6 +96,14 @@ TRANSLATIONS_PER_MINUTE=10
|
|||||||
TRANSLATIONS_PER_HOUR=50
|
TRANSLATIONS_PER_HOUR=50
|
||||||
MAX_CONCURRENT_TRANSLATIONS=5
|
MAX_CONCURRENT_TRANSLATIONS=5
|
||||||
|
|
||||||
|
# ============== Quality Layer (L0) ==============
|
||||||
|
# Track A1 of the dev plan — observability only, no behavior change.
|
||||||
|
# When enabled, the L0 layer logs the script detection result for each
|
||||||
|
# translation job but does NOT modify the file or the job status.
|
||||||
|
# Default: false (opt-in). Set to "true" to enable.
|
||||||
|
QUALITY_L0_ENABLED=false
|
||||||
|
QUALITY_L0_SAMPLE_SIZE=20
|
||||||
|
|
||||||
# ============== Cleanup Service ==============
|
# ============== Cleanup Service ==============
|
||||||
# Enable automatic file cleanup
|
# Enable automatic file cleanup
|
||||||
CLEANUP_ENABLED=true
|
CLEANUP_ENABLED=true
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ class Config:
|
|||||||
)
|
)
|
||||||
MAX_MEMORY_PERCENT = float(os.getenv("MAX_MEMORY_PERCENT", "80"))
|
MAX_MEMORY_PERCENT = float(os.getenv("MAX_MEMORY_PERCENT", "80"))
|
||||||
|
|
||||||
|
# ============== Quality Layer (L0) ==============
|
||||||
|
# Track A1 of the dev plan — observability only, no behavior change.
|
||||||
|
# Set to "true" to enable. Default: false (opt-in).
|
||||||
|
QUALITY_L0_ENABLED = os.getenv("QUALITY_L0_ENABLED", "false").lower() == "true"
|
||||||
|
# Number of text samples to extract from the output file for L0 analysis.
|
||||||
|
# Keep small to avoid overhead. 20 is enough to catch language confusion.
|
||||||
|
QUALITY_L0_SAMPLE_SIZE = int(os.getenv("QUALITY_L0_SAMPLE_SIZE", "20"))
|
||||||
|
|
||||||
|
|
||||||
# ============== API Configuration ==============
|
# ============== API Configuration ==============
|
||||||
API_TITLE = "Document Translation API"
|
API_TITLE = "Document Translation API"
|
||||||
API_VERSION = "1.0.0"
|
API_VERSION = "1.0.0"
|
||||||
|
|||||||
@@ -1354,6 +1354,34 @@ async def _run_translation_job(
|
|||||||
f"{changed}/{attempted} ({ratio:.1%})"
|
f"{changed}/{attempted} ({ratio:.1%})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Quality L0 layer (Track A1 — observability only)
|
||||||
|
# Extract a small sample of text from the output file and run the
|
||||||
|
# L0 quality checks. NEVER blocks the job, NEVER modifies the file.
|
||||||
|
# Enabled by feature flag QUALITY_L0_ENABLED (default: false).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if getattr(config, "QUALITY_L0_ENABLED", False):
|
||||||
|
try:
|
||||||
|
from services.quality import run_l0_check, extract_sample
|
||||||
|
samples = extract_sample(
|
||||||
|
output_path,
|
||||||
|
file_extension,
|
||||||
|
max_samples=getattr(config, "QUALITY_L0_SAMPLE_SIZE", 20),
|
||||||
|
)
|
||||||
|
translated_chunks = [s["translated"] for s in samples]
|
||||||
|
run_l0_check(
|
||||||
|
source_chunks=[""] * len(translated_chunks), # L0 doesn't need source
|
||||||
|
translated_chunks=translated_chunks,
|
||||||
|
target_lang=target_lang,
|
||||||
|
job_id=job_id,
|
||||||
|
file_extension=file_extension,
|
||||||
|
)
|
||||||
|
except Exception as l0_err:
|
||||||
|
# Quality L0 must NEVER break a job. Log and continue.
|
||||||
|
logger.warning(
|
||||||
|
f"Job {job_id}: quality L0 layer failed: {l0_err}"
|
||||||
|
)
|
||||||
|
|
||||||
if user_id:
|
if user_id:
|
||||||
# Determine cost factor based on selected provider and model
|
# Determine cost factor based on selected provider and model
|
||||||
cost_factor = 1
|
cost_factor = 1
|
||||||
|
|||||||
35
services/quality/__init__.py
Normal file
35
services/quality/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Quality check layer for translations.
|
||||||
|
|
||||||
|
Track A1 — L0 backend (observation only).
|
||||||
|
Pure Python, no new dependencies, no network calls.
|
||||||
|
Designed to be ADDITIVE: existing translation flow is untouched.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
QualityCheckResult — per-chunk result dataclass
|
||||||
|
DocumentQualityResult — aggregated result dataclass
|
||||||
|
evaluate_chunk(...) — score a single (source, translation) pair
|
||||||
|
evaluate_document(...) — score a list of pairs and aggregate
|
||||||
|
run_l0_check(...) — defensive wrapper used by the route
|
||||||
|
extract_sample(...) — extract text from a finished file
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .script_detector import (
|
||||||
|
QualityCheckResult,
|
||||||
|
DocumentQualityResult,
|
||||||
|
evaluate_chunk,
|
||||||
|
evaluate_document,
|
||||||
|
detect_arabic_variant,
|
||||||
|
)
|
||||||
|
from .pipeline import run_l0_check
|
||||||
|
from .file_extractor import extract_sample
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"QualityCheckResult",
|
||||||
|
"DocumentQualityResult",
|
||||||
|
"evaluate_chunk",
|
||||||
|
"evaluate_document",
|
||||||
|
"detect_arabic_variant",
|
||||||
|
"run_l0_check",
|
||||||
|
"extract_sample",
|
||||||
|
]
|
||||||
235
services/quality/config.py
Normal file
235
services/quality/config.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
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())
|
||||||
146
services/quality/file_extractor.py
Normal file
146
services/quality/file_extractor.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
File text extractor for the L0 quality layer.
|
||||||
|
|
||||||
|
Extracts a small sample of text from a translated file so the L0 checks
|
||||||
|
can run on a real output without requiring the translators to expose
|
||||||
|
their internal chunk data.
|
||||||
|
|
||||||
|
This module depends on the same libraries the translators use
|
||||||
|
(python-docx, openpyxl, python-pptx, PyMuPDF). All imports are lazy
|
||||||
|
and guarded, so a missing library only blocks the matching format —
|
||||||
|
other formats keep working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class TextSample(TypedDict):
|
||||||
|
"""A (source_placeholder, translated) pair from an output file."""
|
||||||
|
source: str
|
||||||
|
translated: str
|
||||||
|
|
||||||
|
|
||||||
|
# Maximum samples per format — keeps the L0 check fast.
|
||||||
|
DEFAULT_MAX_SAMPLES = 20
|
||||||
|
|
||||||
|
|
||||||
|
def extract_sample(
|
||||||
|
file_path: Path,
|
||||||
|
file_extension: str,
|
||||||
|
max_samples: int = DEFAULT_MAX_SAMPLES,
|
||||||
|
) -> List[TextSample]:
|
||||||
|
"""
|
||||||
|
Extract a sample of translated text strings from a finished file.
|
||||||
|
|
||||||
|
The "source" field is always empty — we don't have the original
|
||||||
|
document at this point. The L0 checks that care about source/target
|
||||||
|
ratio (length_checker) handle empty source gracefully.
|
||||||
|
|
||||||
|
Returns an empty list if the file cannot be read.
|
||||||
|
"""
|
||||||
|
if not file_path or not Path(file_path).exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
ext = (file_extension or Path(file_path).suffix).lower()
|
||||||
|
try:
|
||||||
|
if ext == ".docx":
|
||||||
|
return _extract_docx(file_path, max_samples)
|
||||||
|
if ext == ".xlsx":
|
||||||
|
return _extract_xlsx(file_path, max_samples)
|
||||||
|
if ext == ".pptx":
|
||||||
|
return _extract_pptx(file_path, max_samples)
|
||||||
|
if ext == ".pdf":
|
||||||
|
return _extract_pdf(file_path, max_samples)
|
||||||
|
except Exception:
|
||||||
|
# Any failure: return an empty list. The route will log it.
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_docx(path: Path, max_samples: int) -> List[TextSample]:
|
||||||
|
"""Extract text from a Word document."""
|
||||||
|
from docx import Document
|
||||||
|
doc = Document(str(path))
|
||||||
|
samples: List[TextSample] = []
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
text = (para.text or "").strip()
|
||||||
|
if text and len(text) > 5:
|
||||||
|
samples.append({"source": "", "translated": text})
|
||||||
|
if len(samples) >= max_samples:
|
||||||
|
break
|
||||||
|
# If body had nothing, try tables.
|
||||||
|
if not samples:
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
text = (cell.text or "").strip()
|
||||||
|
if text and len(text) > 5:
|
||||||
|
samples.append({"source": "", "translated": text})
|
||||||
|
if len(samples) >= max_samples:
|
||||||
|
return samples
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_xlsx(path: Path, max_samples: int) -> List[TextSample]:
|
||||||
|
"""Extract text from an Excel file."""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
wb = load_workbook(str(path), data_only=True, read_only=True)
|
||||||
|
samples: List[TextSample] = []
|
||||||
|
try:
|
||||||
|
for ws in wb.worksheets:
|
||||||
|
for row in ws.iter_rows():
|
||||||
|
for cell in row:
|
||||||
|
val = cell.value
|
||||||
|
if isinstance(val, str):
|
||||||
|
text = val.strip()
|
||||||
|
if text and len(text) > 3:
|
||||||
|
samples.append({"source": "", "translated": text})
|
||||||
|
if len(samples) >= max_samples:
|
||||||
|
return samples
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pptx(path: Path, max_samples: int) -> List[TextSample]:
|
||||||
|
"""Extract text from a PowerPoint file."""
|
||||||
|
from pptx import Presentation
|
||||||
|
pres = Presentation(str(path))
|
||||||
|
samples: List[TextSample] = []
|
||||||
|
for slide in pres.slides:
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if not shape.has_text_frame:
|
||||||
|
continue
|
||||||
|
for para in shape.text_frame.paragraphs:
|
||||||
|
text = (para.text or "").strip()
|
||||||
|
if text and len(text) > 3:
|
||||||
|
samples.append({"source": "", "translated": text})
|
||||||
|
if len(samples) >= max_samples:
|
||||||
|
return samples
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf(path: Path, max_samples: int) -> List[TextSample]:
|
||||||
|
"""Extract text from a PDF file (best-effort, layout-preserving format)."""
|
||||||
|
try:
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
samples: List[TextSample] = []
|
||||||
|
doc = fitz.open(str(path))
|
||||||
|
try:
|
||||||
|
for page in doc:
|
||||||
|
text = page.get_text("text") or ""
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and len(line) > 5:
|
||||||
|
samples.append({"source": "", "translated": line})
|
||||||
|
if len(samples) >= max_samples:
|
||||||
|
return samples
|
||||||
|
finally:
|
||||||
|
doc.close()
|
||||||
|
return samples
|
||||||
125
services/quality/length_checker.py
Normal file
125
services/quality/length_checker.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
126
services/quality/pattern_leak.py
Normal file
126
services/quality/pattern_leak.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
Pattern leak detection for the L0 quality layer.
|
||||||
|
|
||||||
|
Detects common failure modes where the LLM translator:
|
||||||
|
1. Leaks parts of the system prompt (e.g. starts with "Translation:" or "Voici la traduction :")
|
||||||
|
2. Hallucinates by repeating the same word/phrase many times in a row
|
||||||
|
3. Returns a chain-of-thought / explanation instead of a translation
|
||||||
|
|
||||||
|
These checks are pure regex / counting — no model, no network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Prompt leak patterns ----------
|
||||||
|
|
||||||
|
# Phrases that strongly suggest the LLM leaked its prompt or a thought process
|
||||||
|
# into the output. We check only at the START of the translation (after
|
||||||
|
# stripping whitespace) to avoid false positives on legitimate text.
|
||||||
|
LEAK_PREFIX_PATTERNS: List[re.Pattern] = [
|
||||||
|
re.compile(r"^(translation|translated text|here is the translation|here'?s the translation)\s*[::-]", re.IGNORECASE),
|
||||||
|
re.compile(r"^(voici (la |ma )?traduction|traduction\s*[::-])\b", re.IGNORECASE),
|
||||||
|
re.compile(r"^(原文|译|翻译|译为|以下是)\s*[::]?", re.UNICODE),
|
||||||
|
re.compile(r"^(sure,?\s+here'?s?\s+(the\s+)?translation|of course,?\s+here)", re.IGNORECASE),
|
||||||
|
re.compile(r"^(\*\*|__|\#)\s*translation", re.IGNORECASE),
|
||||||
|
re.compile(r"^translated from\s+\w+\s+to\s+\w+\s*[::-]", re.IGNORECASE),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Repetition detection ----------
|
||||||
|
|
||||||
|
# A "word" for the repetition check — uses Unicode word boundaries so
|
||||||
|
# it works on Chinese, Japanese, Korean, etc.
|
||||||
|
_WORD_RE = re.compile(r"\S+", re.UNICODE)
|
||||||
|
|
||||||
|
# Threshold: a word (or token) repeated 5+ times consecutively is almost
|
||||||
|
# always a hallucination. We allow up to 4 to handle legitimate text
|
||||||
|
# like "the the" (typo) without false positives.
|
||||||
|
REPETITION_THRESHOLD = 5
|
||||||
|
# Same character repeated 20+ times in a row is also a hallucination
|
||||||
|
# (catches cases like "xxxxxxxxxxx" or "==========").
|
||||||
|
CHAR_REPETITION_THRESHOLD = 20
|
||||||
|
|
||||||
|
|
||||||
|
def check(text: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Returns a dict like:
|
||||||
|
{
|
||||||
|
"issue": None | "prompt_leak" | "repetition_hallucination",
|
||||||
|
"matched_pattern": "..." | None,
|
||||||
|
"repetition_count": int | None,
|
||||||
|
}
|
||||||
|
Never raises.
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return {"issue": None, "matched_pattern": None, "repetition_count": None}
|
||||||
|
|
||||||
|
stripped = text.lstrip()
|
||||||
|
|
||||||
|
# 1. Prompt leak
|
||||||
|
for pat in LEAK_PREFIX_PATTERNS:
|
||||||
|
m = pat.match(stripped)
|
||||||
|
if m:
|
||||||
|
return {
|
||||||
|
"issue": "prompt_leak",
|
||||||
|
"matched_pattern": pat.pattern,
|
||||||
|
"repetition_count": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Token-level repetition
|
||||||
|
tokens = _WORD_RE.findall(stripped)
|
||||||
|
rep_count = _max_consecutive_repetition(tokens)
|
||||||
|
if rep_count >= REPETITION_THRESHOLD:
|
||||||
|
return {
|
||||||
|
"issue": "repetition_hallucination",
|
||||||
|
"matched_pattern": None,
|
||||||
|
"repetition_count": rep_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Character-level repetition (catches "xxxxxx" without spaces)
|
||||||
|
char_rep = _max_consecutive_char_repetition(stripped)
|
||||||
|
if char_rep >= CHAR_REPETITION_THRESHOLD:
|
||||||
|
return {
|
||||||
|
"issue": "repetition_hallucination",
|
||||||
|
"matched_pattern": None,
|
||||||
|
"repetition_count": char_rep,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"issue": None, "matched_pattern": None, "repetition_count": max(rep_count, char_rep) or None}
|
||||||
|
|
||||||
|
|
||||||
|
def _max_consecutive_repetition(tokens: List[str]) -> int:
|
||||||
|
"""Return the maximum number of times the same token appears consecutively."""
|
||||||
|
if not tokens:
|
||||||
|
return 0
|
||||||
|
# Normalize: lower-case + strip basic punctuation for comparison
|
||||||
|
norm = [t.lower().strip(".,!?;:\"'`()[]{}") for t in tokens]
|
||||||
|
max_run = 1
|
||||||
|
current_run = 1
|
||||||
|
for i in range(1, len(norm)):
|
||||||
|
if norm[i] and norm[i] == norm[i - 1]:
|
||||||
|
current_run += 1
|
||||||
|
if current_run > max_run:
|
||||||
|
max_run = current_run
|
||||||
|
else:
|
||||||
|
current_run = 1
|
||||||
|
return max_run
|
||||||
|
|
||||||
|
|
||||||
|
def _max_consecutive_char_repetition(text: str) -> int:
|
||||||
|
"""Return the maximum number of times the same character appears consecutively."""
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
max_run = 1
|
||||||
|
current_run = 1
|
||||||
|
for i in range(1, len(text)):
|
||||||
|
if text[i] == text[i - 1] and not text[i].isspace():
|
||||||
|
current_run += 1
|
||||||
|
if current_run > max_run:
|
||||||
|
max_run = current_run
|
||||||
|
else:
|
||||||
|
current_run = 1
|
||||||
|
return max_run
|
||||||
75
services/quality/pipeline.py
Normal file
75
services/quality/pipeline.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Quality pipeline — defensive wrapper around the L0 checks.
|
||||||
|
|
||||||
|
The pipeline is the integration point for the route. It:
|
||||||
|
1. Catches all exceptions (L0 must NEVER break a translation job)
|
||||||
|
2. Adds timing
|
||||||
|
3. Emits a single structured log line per job
|
||||||
|
|
||||||
|
The actual checks live in `script_detector`, `length_checker`, `pattern_leak`.
|
||||||
|
This module is the orchestration / safety layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from core.logging import get_logger
|
||||||
|
|
||||||
|
from .script_detector import evaluate_document, DocumentQualityResult
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_l0_check(
|
||||||
|
source_chunks: List[str],
|
||||||
|
translated_chunks: List[str],
|
||||||
|
target_lang: Optional[str],
|
||||||
|
job_id: Optional[str] = None,
|
||||||
|
file_extension: Optional[str] = None,
|
||||||
|
) -> DocumentQualityResult:
|
||||||
|
"""
|
||||||
|
Run the L0 quality checks defensively. Never raises.
|
||||||
|
|
||||||
|
Returns an empty/neutral DocumentQualityResult on internal error
|
||||||
|
so the calling route can log and continue without affecting the
|
||||||
|
translation job outcome.
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
empty = DocumentQualityResult(
|
||||||
|
passed=True,
|
||||||
|
score=0.0,
|
||||||
|
chunk_count=0,
|
||||||
|
failed_chunk_count=0,
|
||||||
|
issues={"internal_error": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = evaluate_document(source_chunks, translated_chunks, target_lang)
|
||||||
|
except Exception as e:
|
||||||
|
elapsed_ms = round((time.time() - start) * 1000, 2)
|
||||||
|
logger.warning(
|
||||||
|
"quality_l0_check_failed",
|
||||||
|
job_id=job_id,
|
||||||
|
file_extension=file_extension,
|
||||||
|
error=str(e)[:200],
|
||||||
|
error_type=type(e).__name__,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
)
|
||||||
|
return empty
|
||||||
|
|
||||||
|
elapsed_ms = round((time.time() - start) * 1000, 2)
|
||||||
|
logger.info(
|
||||||
|
"quality_l0_check",
|
||||||
|
job_id=job_id,
|
||||||
|
file_extension=file_extension,
|
||||||
|
target_lang=target_lang,
|
||||||
|
chunk_count=result.chunk_count,
|
||||||
|
failed_chunk_count=result.failed_chunk_count,
|
||||||
|
score=result.score,
|
||||||
|
passed=result.passed,
|
||||||
|
issues=result.issues,
|
||||||
|
elapsed_ms=elapsed_ms,
|
||||||
|
)
|
||||||
|
return result
|
||||||
374
services/quality/script_detector.py
Normal file
374
services/quality/script_detector.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
"""
|
||||||
|
L0 script detector.
|
||||||
|
|
||||||
|
Verifies that a translated string is actually written in the script expected
|
||||||
|
for the target language.
|
||||||
|
|
||||||
|
This is the first line of defense against the most common translation
|
||||||
|
failure mode: the LLM hallucinates text in the wrong language or wrong
|
||||||
|
script (e.g. user asks for Persian, model returns Arabic, or user asks
|
||||||
|
for Hindi, model returns Arabic). The check is purely heuristic — it
|
||||||
|
counts code points in the relevant Unicode ranges and compares to a
|
||||||
|
threshold.
|
||||||
|
|
||||||
|
Pure Python. No network calls. No new dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from . import config as _config
|
||||||
|
from . import length_checker
|
||||||
|
from . import pattern_leak
|
||||||
|
|
||||||
|
from core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Result dataclasses ----------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QualityCheckResult:
|
||||||
|
"""Result of evaluating a single (source, translation) pair."""
|
||||||
|
passed: bool
|
||||||
|
score: float # 0.0 to 1.0
|
||||||
|
issues: List[str] = field(default_factory=list)
|
||||||
|
detected_script: Optional[str] = None
|
||||||
|
expected_script: Optional[str] = None
|
||||||
|
details: Dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_log_dict(self) -> Dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DocumentQualityResult:
|
||||||
|
"""Aggregated result for a list of (source, translation) pairs."""
|
||||||
|
passed: bool
|
||||||
|
score: float # mean score across chunks
|
||||||
|
chunk_count: int
|
||||||
|
failed_chunk_count: int
|
||||||
|
issues: Dict[str, int] = field(default_factory=dict) # issue -> count
|
||||||
|
samples: List[Dict] = field(default_factory=list) # a few example failures
|
||||||
|
|
||||||
|
def to_log_dict(self) -> Dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Core helpers ----------
|
||||||
|
|
||||||
|
def _char_in_ranges(code_point: int, ranges: list) -> bool:
|
||||||
|
"""True if a code point falls in any of the (start, end) ranges."""
|
||||||
|
for start, end in ranges:
|
||||||
|
if start <= code_point <= end:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _count_letters(text: str) -> int:
|
||||||
|
"""Count alphabetic characters (using Python's built-in isalpha)."""
|
||||||
|
return sum(1 for c in text if c.isalpha())
|
||||||
|
|
||||||
|
|
||||||
|
def _count_in_script(text: str, ranges: list) -> int:
|
||||||
|
"""Count how many alphabetic characters fall within the given Unicode ranges."""
|
||||||
|
if not ranges:
|
||||||
|
# 'latin' or unknown — treat all letters as matching.
|
||||||
|
return _count_letters(text)
|
||||||
|
return sum(
|
||||||
|
1 for c in text
|
||||||
|
if c.isalpha() and _char_in_ranges(ord(c), ranges)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Arabic-script variant detection ----------
|
||||||
|
|
||||||
|
def detect_arabic_variant(
|
||||||
|
text: str,
|
||||||
|
claimed_lang: Optional[str],
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
For text that is in the Arabic script block, check whether it matches
|
||||||
|
the specific variant the user asked for (Persian, Urdu, Pashto, etc.).
|
||||||
|
|
||||||
|
Returns a dict like:
|
||||||
|
{
|
||||||
|
"verdict": "pass" | "fail" | "skip",
|
||||||
|
"claimed_lang": "fa",
|
||||||
|
"detected_variants": ["fa"],
|
||||||
|
"reason": "...",
|
||||||
|
}
|
||||||
|
|
||||||
|
Detection logic:
|
||||||
|
1. If the text has < 60% Arabic-script letters overall, verdict = "skip"
|
||||||
|
(the script-detector will catch the mismatch).
|
||||||
|
2. If claimed_lang is NOT an Arabic-script language, verdict = "fail"
|
||||||
|
(this case should have been caught upstream — defensive double-check).
|
||||||
|
3. Scan the text for any discriminating character from any
|
||||||
|
Arabic-script language. If a discriminating character of a
|
||||||
|
DIFFERENT language is found, verdict = "fail".
|
||||||
|
4. Otherwise verdict = "pass".
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return {"verdict": "skip", "claimed_lang": claimed_lang, "reason": "empty text"}
|
||||||
|
|
||||||
|
arabic_ranges = _config.get_ranges("arabic")
|
||||||
|
letters = _count_letters(text)
|
||||||
|
if letters == 0:
|
||||||
|
return {"verdict": "skip", "claimed_lang": claimed_lang, "reason": "no letters"}
|
||||||
|
|
||||||
|
in_arabic = _count_in_script(text, arabic_ranges)
|
||||||
|
arabic_ratio = in_arabic / letters
|
||||||
|
|
||||||
|
if arabic_ratio < _config.MIN_RATIO_IN_SCRIPT:
|
||||||
|
# Not really Arabic-script — let the main script_detector handle it.
|
||||||
|
return {
|
||||||
|
"verdict": "skip",
|
||||||
|
"claimed_lang": claimed_lang,
|
||||||
|
"arabic_ratio": round(arabic_ratio, 3),
|
||||||
|
"reason": "not in Arabic script",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not _config.is_arabic_script_lang(claimed_lang):
|
||||||
|
# The translation IS in Arabic but the target wasn't Arabic.
|
||||||
|
# The main script_detector will fail on this; we just return skip.
|
||||||
|
return {
|
||||||
|
"verdict": "skip",
|
||||||
|
"claimed_lang": claimed_lang,
|
||||||
|
"arabic_ratio": round(arabic_ratio, 3),
|
||||||
|
"reason": "target is not an Arabic-script language",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Now: text is Arabic-script AND target is Arabic-script. Check the variant.
|
||||||
|
detected = set()
|
||||||
|
for lang_code, chars in _config.DISCRIMINATING_CHARS.items():
|
||||||
|
if not chars:
|
||||||
|
continue
|
||||||
|
if any(c in chars for c in text):
|
||||||
|
detected.add(lang_code)
|
||||||
|
|
||||||
|
if detected and claimed_lang and claimed_lang.lower() not in detected:
|
||||||
|
return {
|
||||||
|
"verdict": "fail",
|
||||||
|
"claimed_lang": claimed_lang,
|
||||||
|
"detected_variants": sorted(detected),
|
||||||
|
"arabic_ratio": round(arabic_ratio, 3),
|
||||||
|
"reason": (
|
||||||
|
f"target={claimed_lang} but text contains characters typical of "
|
||||||
|
f"{', '.join(sorted(detected))}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"verdict": "pass",
|
||||||
|
"claimed_lang": claimed_lang,
|
||||||
|
"detected_variants": sorted(detected) if detected else [claimed_lang],
|
||||||
|
"arabic_ratio": round(arabic_ratio, 3),
|
||||||
|
"reason": "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Per-chunk evaluation ----------
|
||||||
|
|
||||||
|
def evaluate_chunk(
|
||||||
|
source_text: str,
|
||||||
|
translated_text: str,
|
||||||
|
target_lang: Optional[str],
|
||||||
|
) -> QualityCheckResult:
|
||||||
|
"""
|
||||||
|
Run the L0 checks on a single (source, translation) pair.
|
||||||
|
|
||||||
|
Returns a QualityCheckResult. The function is purely defensive — it
|
||||||
|
never raises; any internal error results in a "skip" result.
|
||||||
|
"""
|
||||||
|
if translated_text is None:
|
||||||
|
return QualityCheckResult(
|
||||||
|
passed=True, score=0.0, issues=["empty_translation"],
|
||||||
|
details={"reason": "translation is None"},
|
||||||
|
)
|
||||||
|
|
||||||
|
text = translated_text.strip()
|
||||||
|
if not text:
|
||||||
|
return QualityCheckResult(
|
||||||
|
passed=True, score=0.0, issues=["empty_translation"],
|
||||||
|
details={"reason": "translation is empty or whitespace-only"},
|
||||||
|
)
|
||||||
|
|
||||||
|
target_lang = (target_lang or "").lower() or None
|
||||||
|
issues: List[str] = []
|
||||||
|
details: Dict = {}
|
||||||
|
|
||||||
|
# --- Script detection ---
|
||||||
|
expected_script = _config.get_script(target_lang)
|
||||||
|
expected_ranges = _config.get_ranges(expected_script)
|
||||||
|
letters = _count_letters(text)
|
||||||
|
|
||||||
|
if letters == 0:
|
||||||
|
# No alphabetic characters — could be numbers, punctuation, or
|
||||||
|
# a single non-Latin symbol. Skip script check.
|
||||||
|
script_score = 1.0
|
||||||
|
detected_script = expected_script
|
||||||
|
details["script_check"] = "skipped: no alphabetic characters"
|
||||||
|
else:
|
||||||
|
# Always try to determine the ACTUAL script of the text — used for
|
||||||
|
# diagnostics and for catching language confusion when the target
|
||||||
|
# is Latin (e.g. user asks fr, we get Arabic text).
|
||||||
|
detected_script = _detect_actual_script(text)
|
||||||
|
in_expected = _count_in_script(text, expected_ranges)
|
||||||
|
script_score = in_expected / letters
|
||||||
|
|
||||||
|
details["script_score"] = round(script_score, 3)
|
||||||
|
details["letters_in_text"] = letters
|
||||||
|
details["letters_in_script"] = in_expected
|
||||||
|
details["detected_script"] = detected_script
|
||||||
|
details["expected_script"] = expected_script
|
||||||
|
details["min_ratio"] = _config.MIN_RATIO_IN_SCRIPT
|
||||||
|
|
||||||
|
# Two failure modes:
|
||||||
|
# 1. Target is a SPECIFIC non-Latin script (cyrillic, arabic, cjk...)
|
||||||
|
# and the text doesn't match it enough.
|
||||||
|
# 2. Target is Latin but the text is clearly in a SPECIFIC other
|
||||||
|
# script (cyrillic, arabic, devanagari, cjk...) — language
|
||||||
|
# confusion.
|
||||||
|
if expected_script != "latin" and expected_ranges:
|
||||||
|
# Specific non-Latin target.
|
||||||
|
if script_score < _config.MIN_RATIO_IN_SCRIPT:
|
||||||
|
issues.append("wrong_script")
|
||||||
|
details["reason"] = (
|
||||||
|
f"only {script_score:.0%} of letters match {expected_script} script; "
|
||||||
|
f"text appears to be in {detected_script}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Latin target. If detected script is clearly non-Latin, fail.
|
||||||
|
if detected_script and detected_script != "latin" and detected_script != "unknown":
|
||||||
|
# Measure how confident we are that the text is non-Latin.
|
||||||
|
non_latin_ranges = _config.get_ranges(detected_script)
|
||||||
|
in_detected = _count_in_script(text, non_latin_ranges)
|
||||||
|
non_latin_confidence = in_detected / letters
|
||||||
|
if non_latin_confidence >= 0.7:
|
||||||
|
issues.append("wrong_script")
|
||||||
|
details["reason"] = (
|
||||||
|
f"target is Latin but {non_latin_confidence:.0%} of letters "
|
||||||
|
f"are in {detected_script} script — language confusion"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Arabic-script variant detection ---
|
||||||
|
if _config.is_arabic_script_lang(target_lang):
|
||||||
|
variant_result = detect_arabic_variant(text, target_lang)
|
||||||
|
details["arabic_variant"] = variant_result
|
||||||
|
if variant_result["verdict"] == "fail":
|
||||||
|
issues.append("wrong_arabic_variant")
|
||||||
|
|
||||||
|
# --- Length sanity ---
|
||||||
|
length_result = length_checker.check(source_text, text)
|
||||||
|
details["length"] = length_result
|
||||||
|
if length_result.get("issue"):
|
||||||
|
issues.append(length_result["issue"])
|
||||||
|
|
||||||
|
# --- Pattern leak / repetition ---
|
||||||
|
leak_result = pattern_leak.check(text)
|
||||||
|
details["pattern_check"] = leak_result
|
||||||
|
if leak_result.get("issue"):
|
||||||
|
issues.append(leak_result["issue"])
|
||||||
|
|
||||||
|
# --- Aggregate ---
|
||||||
|
passed = len(issues) == 0
|
||||||
|
# Simple score: how many of the 3 main checks passed.
|
||||||
|
n_checks = 3
|
||||||
|
n_failed = sum(
|
||||||
|
1 for issue in issues if issue in (
|
||||||
|
"wrong_script", "wrong_arabic_variant",
|
||||||
|
"length_outlier", "truncation_suspect",
|
||||||
|
"prompt_leak", "repetition_hallucination",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
score = max(0.0, 1.0 - (n_failed / n_checks))
|
||||||
|
|
||||||
|
return QualityCheckResult(
|
||||||
|
passed=passed,
|
||||||
|
score=round(score, 3),
|
||||||
|
issues=issues,
|
||||||
|
detected_script=detected_script,
|
||||||
|
expected_script=expected_script,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_actual_script(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Heuristically determine which script a string is in. Used only for
|
||||||
|
diagnostics — never for the verdict. Returns the first script (in
|
||||||
|
priority order) whose ratio exceeds the threshold.
|
||||||
|
"""
|
||||||
|
letters = _count_letters(text)
|
||||||
|
if letters == 0:
|
||||||
|
return "unknown"
|
||||||
|
# Priority order: more specific scripts first.
|
||||||
|
order = [
|
||||||
|
"hiragana_katakana", "hangul", "cjk", "thai", "lao", "burmese",
|
||||||
|
"khmer", "devanagari", "bengali", "tamil", "telugu", "kannada",
|
||||||
|
"malayalam", "sinhala", "gujarati", "gurmukhi",
|
||||||
|
"arabic", "hebrew", "cyrillic", "greek", "armenian", "georgian",
|
||||||
|
"ethiopic", "tibetan", "thaana",
|
||||||
|
]
|
||||||
|
for script_id in order:
|
||||||
|
ranges = _config.get_ranges(script_id)
|
||||||
|
in_script = _count_in_script(text, ranges)
|
||||||
|
if in_script / letters > 0.4:
|
||||||
|
return script_id
|
||||||
|
return "latin"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Document-level aggregation ----------
|
||||||
|
|
||||||
|
def evaluate_document(
|
||||||
|
source_chunks: List[str],
|
||||||
|
translated_chunks: List[str],
|
||||||
|
target_lang: Optional[str],
|
||||||
|
sample_size: int = 50,
|
||||||
|
) -> DocumentQualityResult:
|
||||||
|
"""
|
||||||
|
Evaluate all (source, translation) pairs and return a document-level
|
||||||
|
summary. The full list is processed but only the first `sample_size`
|
||||||
|
failing chunks are kept in `samples` to keep logs compact.
|
||||||
|
"""
|
||||||
|
n = min(len(source_chunks), len(translated_chunks))
|
||||||
|
chunk_results: List[QualityCheckResult] = []
|
||||||
|
issues_count: Dict[str, int] = {}
|
||||||
|
samples: List[Dict] = []
|
||||||
|
score_sum = 0.0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
r = evaluate_chunk(source_chunks[i], translated_chunks[i], target_lang)
|
||||||
|
chunk_results.append(r)
|
||||||
|
score_sum += r.score
|
||||||
|
for issue in r.issues:
|
||||||
|
issues_count[issue] = issues_count.get(issue, 0) + 1
|
||||||
|
if not r.passed:
|
||||||
|
failed_count += 1
|
||||||
|
if len(samples) < sample_size:
|
||||||
|
src_preview = (source_chunks[i] or "")[:80]
|
||||||
|
trans_preview = (translated_chunks[i] or "")[:80]
|
||||||
|
samples.append({
|
||||||
|
"index": i,
|
||||||
|
"issues": r.issues,
|
||||||
|
"source_preview": src_preview,
|
||||||
|
"translated_preview": trans_preview,
|
||||||
|
"details": r.details,
|
||||||
|
})
|
||||||
|
|
||||||
|
mean_score = (score_sum / n) if n > 0 else 0.0
|
||||||
|
passed = failed_count == 0
|
||||||
|
|
||||||
|
return DocumentQualityResult(
|
||||||
|
passed=passed,
|
||||||
|
score=round(mean_score, 3),
|
||||||
|
chunk_count=n,
|
||||||
|
failed_chunk_count=failed_count,
|
||||||
|
issues=issues_count,
|
||||||
|
samples=samples,
|
||||||
|
)
|
||||||
0
tests/services/quality/__init__.py
Normal file
0
tests/services/quality/__init__.py
Normal file
143
tests/services/quality/test_config.py
Normal file
143
tests/services/quality/test_config.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Tests for services/quality/config.py
|
||||||
|
Covers the language → script mapping and discriminating characters.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.quality import config as qconfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetScript:
|
||||||
|
def test_cyrillic_languages(self):
|
||||||
|
assert qconfig.get_script("ru") == "cyrillic"
|
||||||
|
assert qconfig.get_script("uk") == "cyrillic"
|
||||||
|
assert qconfig.get_script("be") == "cyrillic"
|
||||||
|
assert qconfig.get_script("bg") == "cyrillic"
|
||||||
|
assert qconfig.get_script("sr") == "cyrillic"
|
||||||
|
assert qconfig.get_script("kk") == "cyrillic"
|
||||||
|
|
||||||
|
def test_latin_languages(self):
|
||||||
|
assert qconfig.get_script("en") == "latin"
|
||||||
|
assert qconfig.get_script("fr") == "latin"
|
||||||
|
assert qconfig.get_script("de") == "latin"
|
||||||
|
assert qconfig.get_script("es") == "latin"
|
||||||
|
assert qconfig.get_script("vi") == "latin"
|
||||||
|
assert qconfig.get_script("tr") == "latin"
|
||||||
|
|
||||||
|
def test_cjk_languages(self):
|
||||||
|
assert qconfig.get_script("zh") == "cjk"
|
||||||
|
assert qconfig.get_script("zh-cn") == "cjk"
|
||||||
|
assert qconfig.get_script("zh-tw") == "cjk"
|
||||||
|
|
||||||
|
def test_japanese_uses_kana(self):
|
||||||
|
# ja is mapped to hiragana_katakana specifically so it can be
|
||||||
|
# distinguished from Chinese CJK.
|
||||||
|
assert qconfig.get_script("ja") == "hiragana_katakana"
|
||||||
|
|
||||||
|
def test_korean_uses_hangul(self):
|
||||||
|
assert qconfig.get_script("ko") == "hangul"
|
||||||
|
|
||||||
|
def test_arabic_script_languages(self):
|
||||||
|
assert qconfig.get_script("ar") == "arabic"
|
||||||
|
assert qconfig.get_script("fa") == "arabic"
|
||||||
|
assert qconfig.get_script("ur") == "arabic"
|
||||||
|
assert qconfig.get_script("ps") == "arabic"
|
||||||
|
assert qconfig.get_script("ku") == "arabic"
|
||||||
|
|
||||||
|
def test_hebrew_and_yiddish(self):
|
||||||
|
assert qconfig.get_script("he") == "hebrew"
|
||||||
|
# Yiddish uses Hebrew script, not Arabic
|
||||||
|
assert qconfig.get_script("yi") == "hebrew"
|
||||||
|
|
||||||
|
def test_thaana(self):
|
||||||
|
# Maldivian (Dhivehi) uses Thaana script, not Arabic
|
||||||
|
assert qconfig.get_script("dv") == "thaana"
|
||||||
|
|
||||||
|
def test_indian_scripts(self):
|
||||||
|
assert qconfig.get_script("hi") == "devanagari"
|
||||||
|
assert qconfig.get_script("bn") == "bengali"
|
||||||
|
assert qconfig.get_script("ta") == "tamil"
|
||||||
|
assert qconfig.get_script("te") == "telugu"
|
||||||
|
assert qconfig.get_script("gu") == "gujarati"
|
||||||
|
assert qconfig.get_script("pa") == "gurmukhi"
|
||||||
|
assert qconfig.get_script("ml") == "malayalam"
|
||||||
|
assert qconfig.get_script("kn") == "kannada"
|
||||||
|
assert qconfig.get_script("si") == "sinhala"
|
||||||
|
|
||||||
|
def test_se_asian_scripts(self):
|
||||||
|
assert qconfig.get_script("th") == "thai"
|
||||||
|
assert qconfig.get_script("lo") == "lao"
|
||||||
|
assert qconfig.get_script("my") == "burmese"
|
||||||
|
assert qconfig.get_script("km") == "khmer"
|
||||||
|
|
||||||
|
def test_other_scripts(self):
|
||||||
|
assert qconfig.get_script("el") == "greek"
|
||||||
|
assert qconfig.get_script("ka") == "georgian"
|
||||||
|
assert qconfig.get_script("hy") == "armenian"
|
||||||
|
assert qconfig.get_script("am") == "ethiopic"
|
||||||
|
assert qconfig.get_script("bo") == "tibetan"
|
||||||
|
|
||||||
|
def test_unknown_falls_back_to_latin(self):
|
||||||
|
assert qconfig.get_script("xx") == "latin"
|
||||||
|
assert qconfig.get_script("") == "latin"
|
||||||
|
assert qconfig.get_script(None) == "latin"
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
assert qconfig.get_script("FR") == "latin"
|
||||||
|
assert qconfig.get_script("ZH-CN") == "cjk"
|
||||||
|
assert qconfig.get_script("Ru") == "cyrillic"
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsArabicScriptLang:
|
||||||
|
def test_true_for_arabic_script_langs(self):
|
||||||
|
assert qconfig.is_arabic_script_lang("ar") is True
|
||||||
|
assert qconfig.is_arabic_script_lang("fa") is True
|
||||||
|
assert qconfig.is_arabic_script_lang("ur") is True
|
||||||
|
assert qconfig.is_arabic_script_lang("ckb") is True
|
||||||
|
|
||||||
|
def test_false_for_non_arabic(self):
|
||||||
|
assert qconfig.is_arabic_script_lang("fr") is False
|
||||||
|
assert qconfig.is_arabic_script_lang("he") is False
|
||||||
|
assert qconfig.is_arabic_script_lang("hi") is False
|
||||||
|
assert qconfig.is_arabic_script_lang("en") is False
|
||||||
|
|
||||||
|
def test_false_for_unknown(self):
|
||||||
|
assert qconfig.is_arabic_script_lang("xx") is False
|
||||||
|
assert qconfig.is_arabic_script_lang("") is False
|
||||||
|
assert qconfig.is_arabic_script_lang(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscriminatingChars:
|
||||||
|
def test_persian_chars(self):
|
||||||
|
chars = qconfig.get_discriminating_chars("fa")
|
||||||
|
assert "پ" in chars # peh
|
||||||
|
assert "چ" in chars # tcheh
|
||||||
|
assert "ژ" in chars # zheh
|
||||||
|
assert "گ" in chars # guaf
|
||||||
|
|
||||||
|
def test_urdu_chars(self):
|
||||||
|
chars = qconfig.get_discriminating_chars("ur")
|
||||||
|
assert "ٹ" in chars # tteh
|
||||||
|
assert "ڈ" in chars # ddal
|
||||||
|
|
||||||
|
def test_unknown_lang_empty(self):
|
||||||
|
assert qconfig.get_discriminating_chars("fr") == frozenset()
|
||||||
|
assert qconfig.get_discriminating_chars("") == frozenset()
|
||||||
|
assert qconfig.get_discriminating_chars(None) == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRanges:
|
||||||
|
def test_latin_returns_empty(self):
|
||||||
|
# Latin is the fallback — no ranges, means "anything matches".
|
||||||
|
assert qconfig.get_ranges("latin") == []
|
||||||
|
|
||||||
|
def test_cyrillic_ranges(self):
|
||||||
|
ranges = qconfig.get_ranges("cyrillic")
|
||||||
|
assert any(start == 0x0400 for start, _ in ranges)
|
||||||
|
|
||||||
|
def test_cjk_ranges(self):
|
||||||
|
ranges = qconfig.get_ranges("cjk")
|
||||||
|
assert any(start == 0x4E00 for start, _ in ranges)
|
||||||
|
|
||||||
|
def test_unknown_script_returns_empty(self):
|
||||||
|
assert qconfig.get_ranges("mystery_script") == []
|
||||||
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
|
||||||
49
tests/services/quality/test_length_checker.py
Normal file
49
tests/services/quality/test_length_checker.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
Tests for services/quality/length_checker.py
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.quality.length_checker import check
|
||||||
|
|
||||||
|
|
||||||
|
class TestLengthCheck:
|
||||||
|
def test_normal_length(self):
|
||||||
|
r = check("Hello world, this is a test of translation length",
|
||||||
|
"Bonjour le monde, ceci est un test de longueur de traduction")
|
||||||
|
assert r["issue"] is None
|
||||||
|
assert r["ratio"] is not None
|
||||||
|
assert 0.5 < r["ratio"] < 2.0
|
||||||
|
|
||||||
|
def test_huge_translation(self):
|
||||||
|
src = "A" * 200
|
||||||
|
r = check(src, "x" * 1000)
|
||||||
|
assert r["issue"] == "length_outlier"
|
||||||
|
assert r["ratio"] > 3.5
|
||||||
|
|
||||||
|
def test_tiny_translation(self):
|
||||||
|
r = check("A" * 100, "ok")
|
||||||
|
assert r["issue"] == "truncation_suspect"
|
||||||
|
assert r["ratio"] < 0.15
|
||||||
|
|
||||||
|
def test_empty_translation_flagged(self):
|
||||||
|
r = check("Hello world, this is a test", "")
|
||||||
|
assert r["issue"] == "truncation_suspect"
|
||||||
|
|
||||||
|
def test_short_source_skips_ratio(self):
|
||||||
|
r = check("Hi", "ok")
|
||||||
|
assert r["issue"] is None
|
||||||
|
assert r["ratio"] is None
|
||||||
|
|
||||||
|
def test_empty_inputs(self):
|
||||||
|
r = check("", "")
|
||||||
|
assert r["issue"] is None
|
||||||
|
assert r["source_length"] == 0
|
||||||
|
assert r["translated_length"] == 0
|
||||||
|
|
||||||
|
def test_none_source(self):
|
||||||
|
r = check(None, "translation")
|
||||||
|
assert r["issue"] is None
|
||||||
|
|
||||||
|
def test_numeric_source(self):
|
||||||
|
r = check("Price: 100", "100")
|
||||||
|
assert r["issue"] is None # numeric sources skip the check
|
||||||
85
tests/services/quality/test_pattern_leak.py
Normal file
85
tests/services/quality/test_pattern_leak.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Tests for services/quality/pattern_leak.py
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.quality.pattern_leak import check
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptLeak:
|
||||||
|
def test_english_translation_prefix(self):
|
||||||
|
r = check("Translation: Bonjour le monde")
|
||||||
|
assert r["issue"] == "prompt_leak"
|
||||||
|
|
||||||
|
def test_here_is_translation(self):
|
||||||
|
r = check("Here is the translation: Bonjour")
|
||||||
|
assert r["issue"] == "prompt_leak"
|
||||||
|
|
||||||
|
def test_french_voici_traduction(self):
|
||||||
|
r = check("Voici la traduction : Bonjour le monde")
|
||||||
|
assert r["issue"] == "prompt_leak"
|
||||||
|
|
||||||
|
def test_chinese_translation_prefix(self):
|
||||||
|
r = check("翻译:你好世界")
|
||||||
|
assert r["issue"] == "prompt_leak"
|
||||||
|
|
||||||
|
def test_sure_heres_translation(self):
|
||||||
|
r = check("Sure, here's the translation: Hello world")
|
||||||
|
assert r["issue"] == "prompt_leak"
|
||||||
|
|
||||||
|
def test_of_course_heres(self):
|
||||||
|
r = check("Of course, here you go: Bonjour")
|
||||||
|
assert r["issue"] == "prompt_leak"
|
||||||
|
|
||||||
|
def test_markdown_bold_translation(self):
|
||||||
|
r = check("**Translation** Bonjour le monde")
|
||||||
|
assert r["issue"] == "prompt_leak"
|
||||||
|
|
||||||
|
def test_normal_text_passes(self):
|
||||||
|
r = check("Bonjour le monde, comment allez-vous?")
|
||||||
|
assert r["issue"] is None
|
||||||
|
|
||||||
|
def test_text_with_translation_word_in_middle_passes(self):
|
||||||
|
r = check("Voici ce que je pense de la traduction de ce texte")
|
||||||
|
# The word "traduction" appears but not as a prefix
|
||||||
|
assert r["issue"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepetitionHallucination:
|
||||||
|
def test_long_repetition_detected(self):
|
||||||
|
r = check("the the the the the the the the")
|
||||||
|
assert r["issue"] == "repetition_hallucination"
|
||||||
|
assert r["repetition_count"] >= 5
|
||||||
|
|
||||||
|
def test_short_repetition_passes(self):
|
||||||
|
# 4 times is within tolerance
|
||||||
|
r = check("I think I think I think I think")
|
||||||
|
assert r["issue"] is None
|
||||||
|
|
||||||
|
def test_normal_text_passes(self):
|
||||||
|
r = check("This is a normal sentence with no repetition issues")
|
||||||
|
assert r["issue"] is None
|
||||||
|
|
||||||
|
def test_mixed_repetition_in_long_text_passes(self):
|
||||||
|
# Repetition is local, not a hallucination
|
||||||
|
r = check("The cat sat on the mat. The cat was happy. The dog was sad.")
|
||||||
|
assert r["issue"] is None
|
||||||
|
|
||||||
|
def test_repetition_with_punctuation(self):
|
||||||
|
# "the, the, the, the, the" should still be detected
|
||||||
|
r = check("the, the, the, the, the, the")
|
||||||
|
assert r["issue"] == "repetition_hallucination"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyAndEdgeCases:
|
||||||
|
def test_empty_text(self):
|
||||||
|
r = check("")
|
||||||
|
assert r["issue"] is None
|
||||||
|
|
||||||
|
def test_whitespace_only(self):
|
||||||
|
r = check(" \n \t ")
|
||||||
|
assert r["issue"] is None
|
||||||
|
|
||||||
|
def test_none_input(self):
|
||||||
|
r = check(None)
|
||||||
|
assert r["issue"] is None
|
||||||
61
tests/services/quality/test_pipeline.py
Normal file
61
tests/services/quality/test_pipeline.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Tests for services/quality/pipeline.py
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.quality import run_l0_check
|
||||||
|
from services.quality.script_detector import DocumentQualityResult
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunL0Check:
|
||||||
|
def test_returns_result_on_success(self):
|
||||||
|
result = run_l0_check(
|
||||||
|
["Hello", "World"],
|
||||||
|
["Bonjour", "Monde"],
|
||||||
|
"fr",
|
||||||
|
job_id="test_job_1",
|
||||||
|
)
|
||||||
|
assert isinstance(result, DocumentQualityResult)
|
||||||
|
assert result.passed is True
|
||||||
|
assert result.chunk_count == 2
|
||||||
|
|
||||||
|
def test_returns_neutral_on_empty_lists(self):
|
||||||
|
result = run_l0_check([], [], "fr", job_id="test_job_2")
|
||||||
|
assert result.passed is True
|
||||||
|
assert result.chunk_count == 0
|
||||||
|
|
||||||
|
def test_detects_failures(self):
|
||||||
|
result = run_l0_check(
|
||||||
|
["Hello", "World"],
|
||||||
|
["Bonjour", "مرحبا"],
|
||||||
|
"fr",
|
||||||
|
job_id="test_job_3",
|
||||||
|
)
|
||||||
|
assert result.passed is False
|
||||||
|
assert "wrong_script" in result.issues
|
||||||
|
|
||||||
|
def test_never_raises_on_bad_input(self):
|
||||||
|
# Even with weird input, it shouldn't raise
|
||||||
|
result = run_l0_check(
|
||||||
|
["Hello"],
|
||||||
|
[None], # None translation
|
||||||
|
"fr",
|
||||||
|
job_id="test_job_4",
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_optional_job_id(self):
|
||||||
|
# job_id is optional
|
||||||
|
result = run_l0_check(["hi"], ["salut"], "fr")
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_optional_file_extension(self):
|
||||||
|
result = run_l0_check(
|
||||||
|
["hi"], ["salut"], "fr", file_extension=".docx"
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_target_lang_none(self):
|
||||||
|
# Should not crash with no target lang
|
||||||
|
result = run_l0_check(["hi"], ["salut"], None)
|
||||||
|
assert result is not None
|
||||||
290
tests/services/quality/test_script_detector.py
Normal file
290
tests/services/quality/test_script_detector.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Tests for services/quality/script_detector.py
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.quality import evaluate_chunk, evaluate_document, detect_arabic_variant
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Happy path: correct script ----------
|
||||||
|
|
||||||
|
class TestCorrectScript:
|
||||||
|
def test_russian_correct(self):
|
||||||
|
r = evaluate_chunk("Hello world", "Привет мир", "ru")
|
||||||
|
assert r.passed is True
|
||||||
|
assert "wrong_script" not in r.issues
|
||||||
|
|
||||||
|
def test_chinese_simplified_correct(self):
|
||||||
|
r = evaluate_chunk("Hello", "你好世界", "zh")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_arabic_correct(self):
|
||||||
|
r = evaluate_chunk("Hello", "مرحبا بالعالم", "ar")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_persian_correct(self):
|
||||||
|
# Persian has unique chars چ پ ژ گ
|
||||||
|
r = evaluate_chunk("Hello", "سلام چطوری؟ من پژوهشگر هستم", "fa")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_french_correct(self):
|
||||||
|
r = evaluate_chunk("Hello world", "Bonjour le monde", "fr")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_hebrew_correct(self):
|
||||||
|
r = evaluate_chunk("Hello", "שלום עולם", "he")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_korean_correct(self):
|
||||||
|
r = evaluate_chunk("Hello", "안녕하세요 세계", "ko")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_japanese_correct(self):
|
||||||
|
# Japanese must contain hiragana or katakana to be classified as ja.
|
||||||
|
r = evaluate_chunk("Hello", "こんにちは世界", "ja")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_hindi_correct(self):
|
||||||
|
r = evaluate_chunk("Hello", "नमस्ते दुनिया", "hi")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_thai_correct(self):
|
||||||
|
r = evaluate_chunk("Hello", "สวัสดีชาวโลก", "th")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_greek_correct(self):
|
||||||
|
r = evaluate_chunk("Hello", "Γεια σας κόσμε", "el")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Wrong script: language confusion ----------
|
||||||
|
|
||||||
|
class TestWrongScript:
|
||||||
|
def test_french_text_for_japanese_target(self):
|
||||||
|
r = evaluate_chunk("Hello", "Bonjour le monde", "ja")
|
||||||
|
assert r.passed is False
|
||||||
|
assert "wrong_script" in r.issues
|
||||||
|
|
||||||
|
def test_arabic_text_for_french_target(self):
|
||||||
|
r = evaluate_chunk("Hello", "مرحبا بالعالم", "fr")
|
||||||
|
assert r.passed is False
|
||||||
|
assert "wrong_script" in r.issues
|
||||||
|
|
||||||
|
def test_russian_text_for_english_target(self):
|
||||||
|
r = evaluate_chunk("Hello", "Привет мир", "en")
|
||||||
|
assert r.passed is False
|
||||||
|
assert "wrong_script" in r.issues
|
||||||
|
|
||||||
|
def test_chinese_text_for_korean_target(self):
|
||||||
|
r = evaluate_chunk("Hello", "你好世界", "ko")
|
||||||
|
# Hangul syllables are 0xAC00-0xD7AF — Chinese chars are not in that range.
|
||||||
|
assert r.passed is False
|
||||||
|
assert "wrong_script" in r.issues
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Persian / Arabic discrimination ----------
|
||||||
|
|
||||||
|
class TestArabicVariantDiscrimination:
|
||||||
|
def test_persian_for_arabic_target_fails(self):
|
||||||
|
# Persian text with چ پ ژ گ should fail when target is Arabic.
|
||||||
|
r = evaluate_chunk("Hello", "سلام چطوری؟", "ar")
|
||||||
|
assert r.passed is False
|
||||||
|
assert "wrong_arabic_variant" in r.issues
|
||||||
|
|
||||||
|
def test_arabic_for_persian_target_fails(self):
|
||||||
|
# Pure Arabic text (no Persian-specific chars) when target is Persian.
|
||||||
|
r = evaluate_chunk("Hello", "السلام عليكم", "fa")
|
||||||
|
# No Persian-specific chars, no other Arabic-script variant chars
|
||||||
|
# → should pass the variant check (because no discrimination signal).
|
||||||
|
# This is a known limitation: pure Arabic indistinguishable from pure Persian.
|
||||||
|
# We accept that the variant check only flags MISMATCHES with discriminating chars.
|
||||||
|
assert "wrong_arabic_variant" not in r.issues
|
||||||
|
|
||||||
|
def test_urdu_for_persian_target_fails(self):
|
||||||
|
# Urdu with ٹ ڈ should fail when target is Persian.
|
||||||
|
r = evaluate_chunk("Hello", "السلام ٹڈ", "fa")
|
||||||
|
assert r.passed is False
|
||||||
|
assert "wrong_arabic_variant" in r.issues
|
||||||
|
|
||||||
|
def test_pashto_for_arabic_target_fails(self):
|
||||||
|
# Pashto with ټډړ should fail when target is Arabic.
|
||||||
|
r = evaluate_chunk("Hello", "السلام ټډړ", "ar")
|
||||||
|
assert r.passed is False
|
||||||
|
assert "wrong_arabic_variant" in r.issues
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Edge cases ----------
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
def test_empty_translation_passes(self):
|
||||||
|
# Empty translations are skipped (the provider may have legitimately
|
||||||
|
# produced nothing — e.g. an empty cell).
|
||||||
|
r = evaluate_chunk("Hello", "", "fr")
|
||||||
|
assert r.passed is True
|
||||||
|
assert "empty_translation" in r.issues
|
||||||
|
|
||||||
|
def test_whitespace_only_passes(self):
|
||||||
|
r = evaluate_chunk("Hello", " \n ", "fr")
|
||||||
|
assert r.passed is True
|
||||||
|
assert "empty_translation" in r.issues
|
||||||
|
|
||||||
|
def test_none_translation_passes(self):
|
||||||
|
r = evaluate_chunk("Hello", None, "fr")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_numbers_only_passes(self):
|
||||||
|
r = evaluate_chunk("Price: 100", "100", "fr")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_unknown_target_lang_falls_back(self):
|
||||||
|
# Unknown lang → latin → no script check, just length/leak checks.
|
||||||
|
r = evaluate_chunk("Hello", "Bonjour", "xx")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
def test_mixed_brands_latin_target(self):
|
||||||
|
# "iPhone 15 Pro Max" is mostly Latin + digits — fine for fr.
|
||||||
|
r = evaluate_chunk("Buy iPhone 15", "Acheter iPhone 15", "fr")
|
||||||
|
assert r.passed is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Length sanity ----------
|
||||||
|
|
||||||
|
class TestLengthIssues:
|
||||||
|
def test_huge_translation_flagged_via_repetition(self):
|
||||||
|
# Source too short for length ratio, but the "x"*1000 repetition
|
||||||
|
# is caught by the pattern/repetition check, not length.
|
||||||
|
r = evaluate_chunk("Short", "x" * 1000, "fr")
|
||||||
|
assert "repetition_hallucination" in r.issues
|
||||||
|
|
||||||
|
def test_huge_translation_with_long_source_flagged(self):
|
||||||
|
# Long enough source → length ratio kicks in → length_outlier.
|
||||||
|
src = "A" * 200
|
||||||
|
r = evaluate_chunk(src, "x" * 1000, "fr")
|
||||||
|
assert "length_outlier" in r.issues
|
||||||
|
|
||||||
|
def test_tiny_translation_flagged(self):
|
||||||
|
r = evaluate_chunk("A" * 100, "ok", "fr")
|
||||||
|
# ratio is 2/100 = 0.02, well below 0.15
|
||||||
|
assert "truncation_suspect" in r.issues
|
||||||
|
|
||||||
|
def test_normal_translation_no_length_issue(self):
|
||||||
|
r = evaluate_chunk("Hello world, how are you?", "Bonjour le monde, comment allez-vous?", "fr")
|
||||||
|
assert "length_outlier" not in r.issues
|
||||||
|
assert "truncation_suspect" not in r.issues
|
||||||
|
|
||||||
|
def test_short_source_skips_ratio(self):
|
||||||
|
r = evaluate_chunk("Hi", "ok", "fr")
|
||||||
|
# source too short for ratio check
|
||||||
|
assert "length_outlier" not in r.issues
|
||||||
|
assert "truncation_suspect" not in r.issues
|
||||||
|
|
||||||
|
def test_numeric_source_skips_length_check(self):
|
||||||
|
# Source is mostly digits — translation can also be short.
|
||||||
|
r = evaluate_chunk("Price: 100", "100", "fr")
|
||||||
|
assert "truncation_suspect" not in r.issues
|
||||||
|
assert "length_outlier" not in r.issues
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Pattern leak / repetition ----------
|
||||||
|
|
||||||
|
class TestPatternLeak:
|
||||||
|
def test_prompt_leak_english_detected(self):
|
||||||
|
r = evaluate_chunk("Hello", "Translation: Bonjour le monde", "fr")
|
||||||
|
assert "prompt_leak" in r.issues
|
||||||
|
|
||||||
|
def test_prompt_leak_french_detected(self):
|
||||||
|
r = evaluate_chunk("Hello", "Voici la traduction : Bonjour", "fr")
|
||||||
|
assert "prompt_leak" in r.issues
|
||||||
|
|
||||||
|
def test_prompt_leak_chinese_detected(self):
|
||||||
|
r = evaluate_chunk("Hello", "翻译:你好", "zh")
|
||||||
|
assert "prompt_leak" in r.issues
|
||||||
|
|
||||||
|
def test_repetition_hallucination_detected(self):
|
||||||
|
r = evaluate_chunk("Hello", "the the the the the the", "fr")
|
||||||
|
assert "repetition_hallucination" in r.issues
|
||||||
|
|
||||||
|
def test_normal_text_no_leak(self):
|
||||||
|
r = evaluate_chunk("Hello", "Bonjour le monde", "fr")
|
||||||
|
assert "prompt_leak" not in r.issues
|
||||||
|
assert "repetition_hallucination" not in r.issues
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Document-level aggregation ----------
|
||||||
|
|
||||||
|
class TestEvaluateDocument:
|
||||||
|
def test_all_good(self):
|
||||||
|
result = evaluate_document(
|
||||||
|
["Hello", "World", "Good morning"],
|
||||||
|
["Bonjour", "Monde", "Bonjour"],
|
||||||
|
"fr",
|
||||||
|
)
|
||||||
|
assert result.passed is True
|
||||||
|
assert result.failed_chunk_count == 0
|
||||||
|
assert result.chunk_count == 3
|
||||||
|
|
||||||
|
def test_some_failures(self):
|
||||||
|
result = evaluate_document(
|
||||||
|
["Hello", "World", "Good morning"],
|
||||||
|
["Bonjour", "مرحبا", "Bonjour"],
|
||||||
|
"fr",
|
||||||
|
)
|
||||||
|
assert result.passed is False
|
||||||
|
assert result.failed_chunk_count == 1
|
||||||
|
assert "wrong_script" in result.issues
|
||||||
|
assert len(result.samples) == 1
|
||||||
|
|
||||||
|
def test_empty_lists(self):
|
||||||
|
result = evaluate_document([], [], "fr")
|
||||||
|
assert result.passed is True
|
||||||
|
assert result.chunk_count == 0
|
||||||
|
assert result.score == 0.0
|
||||||
|
|
||||||
|
def test_sample_size_caps_samples(self):
|
||||||
|
# 10 failures, sample_size=3
|
||||||
|
result = evaluate_document(
|
||||||
|
["hi"] * 10,
|
||||||
|
["مرحبا"] * 10,
|
||||||
|
"fr",
|
||||||
|
sample_size=3,
|
||||||
|
)
|
||||||
|
assert result.failed_chunk_count == 10
|
||||||
|
assert len(result.samples) == 3
|
||||||
|
|
||||||
|
def test_score_proportional(self):
|
||||||
|
# 4 chunks, 1 fails → score should be ~0.75 (3/4 passed checks)
|
||||||
|
result = evaluate_document(
|
||||||
|
["hi", "hi", "hi", "hi"],
|
||||||
|
["bonjour", "bonjour", "مرحبا", "bonjour"],
|
||||||
|
"fr",
|
||||||
|
)
|
||||||
|
assert result.failed_chunk_count == 1
|
||||||
|
assert result.score < 1.0
|
||||||
|
assert result.score > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- detect_arabic_variant direct ----------
|
||||||
|
|
||||||
|
class TestDetectArabicVariantDirect:
|
||||||
|
def test_empty_text(self):
|
||||||
|
r = detect_arabic_variant("", "fa")
|
||||||
|
assert r["verdict"] == "skip"
|
||||||
|
|
||||||
|
def test_pure_persian_for_persian(self):
|
||||||
|
r = detect_arabic_variant("سلام چطوری", "fa")
|
||||||
|
assert r["verdict"] == "pass"
|
||||||
|
|
||||||
|
def test_pure_persian_for_arabic(self):
|
||||||
|
r = detect_arabic_variant("سلام چطوری", "ar")
|
||||||
|
assert r["verdict"] == "fail"
|
||||||
|
assert "fa" in r["detected_variants"]
|
||||||
|
|
||||||
|
def test_pure_urdu_for_persian(self):
|
||||||
|
r = detect_arabic_variant("السلام ٹڈ", "fa")
|
||||||
|
assert r["verdict"] == "fail"
|
||||||
|
assert "ur" in r["detected_variants"]
|
||||||
|
|
||||||
|
def test_non_arabic_text(self):
|
||||||
|
r = detect_arabic_variant("Bonjour le monde", "fa")
|
||||||
|
# Not in Arabic script → skip
|
||||||
|
assert r["verdict"] == "skip"
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"clean": "rm -rf dist server.js",
|
"clean": "rm -rf dist server.js",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test:quality": "node --experimental-strip-types --test tests/utils/scriptDetector.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
|
|||||||
739
wordly.art---traduction-de-documents/src/utils/scriptDetector.ts
Normal file
739
wordly.art---traduction-de-documents/src/utils/scriptDetector.ts
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
/**
|
||||||
|
* L0 quality detector — TypeScript mirror of services/quality/ in Python.
|
||||||
|
*
|
||||||
|
* Detects:
|
||||||
|
* - wrong_script: translation is in the wrong script for the target language
|
||||||
|
* (e.g. user asks French, model returns Arabic — language confusion)
|
||||||
|
* - wrong_arabic_variant: for Arabic-script targets (ar/fa/ur/...),
|
||||||
|
* the translation uses characters of a DIFFERENT Arabic-script language
|
||||||
|
* (e.g. user asks Persian, model returns Urdu)
|
||||||
|
* - length_outlier / truncation_suspect: translation is wildly different
|
||||||
|
* in length from the source
|
||||||
|
* - prompt_leak: translation starts with "Translation:" or similar
|
||||||
|
* - repetition_hallucination: "xxx..." or "the the the the" pattern
|
||||||
|
*
|
||||||
|
* Zero dependencies. Works in browser AND in Node.js (for tests).
|
||||||
|
* Designed to be ADDITIVE — never blocks the translation flow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------- Types ----------
|
||||||
|
|
||||||
|
export interface QualityCheckResult {
|
||||||
|
passed: boolean;
|
||||||
|
score: number; // 0.0 to 1.0
|
||||||
|
issues: string[];
|
||||||
|
detectedScript: string | null;
|
||||||
|
expectedScript: string | null;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentQualityResult {
|
||||||
|
passed: boolean;
|
||||||
|
score: number;
|
||||||
|
chunkCount: number;
|
||||||
|
failedChunkCount: number;
|
||||||
|
issues: Record<string, number>;
|
||||||
|
samples: Array<{
|
||||||
|
index: number;
|
||||||
|
issues: string[];
|
||||||
|
sourcePreview: string;
|
||||||
|
translatedPreview: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Unicode ranges per script ----------
|
||||||
|
// Mirrors services/quality/config.py in Python.
|
||||||
|
|
||||||
|
type Range = readonly [number, number]; // [start, end] inclusive
|
||||||
|
|
||||||
|
const UNICODE_RANGES: Record<string, readonly Range[]> = {
|
||||||
|
cyrillic: [
|
||||||
|
[0x0400, 0x04ff],
|
||||||
|
[0x0500, 0x052f],
|
||||||
|
],
|
||||||
|
greek: [
|
||||||
|
[0x0370, 0x03ff],
|
||||||
|
],
|
||||||
|
arabic: [
|
||||||
|
[0x0600, 0x06ff],
|
||||||
|
[0x0750, 0x077f],
|
||||||
|
[0x08a0, 0x08ff],
|
||||||
|
],
|
||||||
|
hebrew: [
|
||||||
|
[0x0590, 0x05ff],
|
||||||
|
],
|
||||||
|
devanagari: [
|
||||||
|
[0x0900, 0x097f],
|
||||||
|
],
|
||||||
|
bengali: [
|
||||||
|
[0x0980, 0x09ff],
|
||||||
|
],
|
||||||
|
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],
|
||||||
|
[0x3400, 0x4dbf],
|
||||||
|
],
|
||||||
|
hiragana_katakana: [
|
||||||
|
[0x3040, 0x309f],
|
||||||
|
[0x30a0, 0x30ff],
|
||||||
|
],
|
||||||
|
hangul: [
|
||||||
|
[0xac00, 0xd7af],
|
||||||
|
[0x1100, 0x11ff],
|
||||||
|
[0xa960, 0xa97f],
|
||||||
|
],
|
||||||
|
georgian: [
|
||||||
|
[0x10a0, 0x10ff],
|
||||||
|
],
|
||||||
|
armenian: [
|
||||||
|
[0x0530, 0x058f],
|
||||||
|
],
|
||||||
|
ethiopic: [
|
||||||
|
[0x1200, 0x137f],
|
||||||
|
[0x1380, 0x139f],
|
||||||
|
],
|
||||||
|
tibetan: [
|
||||||
|
[0x0f00, 0x0fff],
|
||||||
|
],
|
||||||
|
thaana: [
|
||||||
|
[0x0780, 0x07bf],
|
||||||
|
],
|
||||||
|
latin: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Language → script mapping ----------
|
||||||
|
|
||||||
|
const LANG_TO_SCRIPT: Record<string, string> = {
|
||||||
|
// Latin-script languages
|
||||||
|
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', af: 'latin', cy: 'latin', lb: 'latin', fo: 'latin',
|
||||||
|
br: 'latin', co: 'latin', fy: 'latin', gd: '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',
|
||||||
|
// Cyrillic overrides
|
||||||
|
ru: 'cyrillic', uk: 'cyrillic', be: 'cyrillic', sr: 'cyrillic',
|
||||||
|
mk: 'cyrillic', bg: 'cyrillic', ce: 'cyrillic', cv: 'cyrillic',
|
||||||
|
sah: 'cyrillic', udm: 'cyrillic', rue: 'cyrillic', ab: 'cyrillic',
|
||||||
|
// Greek
|
||||||
|
el: 'greek',
|
||||||
|
// Arabic-script languages
|
||||||
|
ar: 'arabic', fa: 'arabic', ur: 'arabic', ps: 'arabic',
|
||||||
|
ku: 'arabic', sd: 'arabic', ckb: 'arabic', bal: 'arabic',
|
||||||
|
bqi: 'arabic', glk: 'arabic', mzn: 'arabic',
|
||||||
|
// Hebrew & Yiddish
|
||||||
|
he: 'hebrew',
|
||||||
|
yi: 'hebrew', // Yiddish uses Hebrew script, not Arabic
|
||||||
|
// Thaana (Maldivian)
|
||||||
|
dv: 'thaana', // Maldivian uses Thaana, not Arabic
|
||||||
|
// Indian scripts
|
||||||
|
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',
|
||||||
|
// SE Asia
|
||||||
|
th: 'thai',
|
||||||
|
lo: 'lao',
|
||||||
|
my: 'burmese',
|
||||||
|
km: 'khmer',
|
||||||
|
// East Asia
|
||||||
|
zh: 'cjk', 'zh-cn': 'cjk', 'zh-tw': 'cjk', 'zh-hk': 'cjk',
|
||||||
|
ja: 'hiragana_katakana',
|
||||||
|
ko: 'hangul',
|
||||||
|
// Others
|
||||||
|
ka: 'georgian',
|
||||||
|
hy: 'armenian',
|
||||||
|
am: 'ethiopic', ti: 'ethiopic', gez: 'ethiopic', tig: 'ethiopic',
|
||||||
|
bo: 'tibetan', dz: 'tibetan',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Discriminating characters for Arabic-script languages ----------
|
||||||
|
|
||||||
|
const DISCRIMINATING_CHARS: Record<string, ReadonlySet<string>> = {
|
||||||
|
fa: new Set('پچژگ'), // Persian
|
||||||
|
ur: new Set('ٹڈڑے'), // Urdu
|
||||||
|
ps: new Set('ټډړږښ'), // Pashto
|
||||||
|
ku: new Set('ڕێ'), // Kurdish
|
||||||
|
ckb: new Set('ڕێ'), // Sorani Kurdish
|
||||||
|
sd: new Set('ٿ'), // Sindhi
|
||||||
|
ug: new Set('ۇۆې'), // Uyghur
|
||||||
|
bal: new Set('ێ'), // Balochi
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Thresholds ----------
|
||||||
|
|
||||||
|
const MIN_RATIO_IN_SCRIPT = 0.60;
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
|
/** Get the script id for a language code. */
|
||||||
|
export function getScript(langCode: string | null | undefined): string {
|
||||||
|
if (!langCode) return 'latin';
|
||||||
|
return LANG_TO_SCRIPT[langCode.toLowerCase()] ?? 'latin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the language uses an Arabic-script block. */
|
||||||
|
export function isArabicScriptLang(langCode: string | null | undefined): boolean {
|
||||||
|
return getScript(langCode) === 'arabic';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the Unicode ranges for a script id. Empty array for 'latin'. */
|
||||||
|
function getRanges(scriptId: string): readonly Range[] {
|
||||||
|
return UNICODE_RANGES[scriptId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Iterate code points of a string (handles surrogate pairs correctly). */
|
||||||
|
function* codePoints(text: string): Generator<number> {
|
||||||
|
for (const ch of text) {
|
||||||
|
yield ch.codePointAt(0) ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a code point falls in any of the (start, end) ranges. */
|
||||||
|
function isInRanges(code: number, ranges: readonly Range[]): boolean {
|
||||||
|
for (const [start, end] of ranges) {
|
||||||
|
if (code >= start && code <= end) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LETTER_REGEX = /\p{L}/u;
|
||||||
|
|
||||||
|
/** Count alphabetic characters (using Unicode property escapes). */
|
||||||
|
function countLetters(text: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
if (LETTER_REGEX.test(ch)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count how many alphabetic characters fall within the given ranges. */
|
||||||
|
function countInScript(text: string, ranges: readonly Range[]): number {
|
||||||
|
if (ranges.length === 0) {
|
||||||
|
// Latin / unknown — all letters match.
|
||||||
|
return countLetters(text);
|
||||||
|
}
|
||||||
|
let count = 0;
|
||||||
|
for (const ch of text) {
|
||||||
|
if (!LETTER_REGEX.test(ch)) continue;
|
||||||
|
const cp = ch.codePointAt(0) ?? 0;
|
||||||
|
if (isInRanges(cp, ranges)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Arabic-script variant detection ----------
|
||||||
|
|
||||||
|
export interface ArabicVariantResult {
|
||||||
|
verdict: 'pass' | 'fail' | 'skip';
|
||||||
|
claimedLang: string | null;
|
||||||
|
detectedVariants: string[];
|
||||||
|
arabicRatio?: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectArabicVariant(
|
||||||
|
text: string,
|
||||||
|
claimedLang: string | null,
|
||||||
|
): ArabicVariantResult {
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
return { verdict: 'skip', claimedLang, detectedVariants: [], reason: 'empty text' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const arabicRanges = getRanges('arabic');
|
||||||
|
const letters = countLetters(text);
|
||||||
|
if (letters === 0) {
|
||||||
|
return { verdict: 'skip', claimedLang, detectedVariants: [], reason: 'no letters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inArabic = countInScript(text, arabicRanges);
|
||||||
|
const arabicRatio = inArabic / letters;
|
||||||
|
|
||||||
|
if (arabicRatio < MIN_RATIO_IN_SCRIPT) {
|
||||||
|
return {
|
||||||
|
verdict: 'skip',
|
||||||
|
claimedLang,
|
||||||
|
detectedVariants: [],
|
||||||
|
arabicRatio: round(arabicRatio, 3),
|
||||||
|
reason: 'not in Arabic script',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isArabicScriptLang(claimedLang)) {
|
||||||
|
return {
|
||||||
|
verdict: 'skip',
|
||||||
|
claimedLang,
|
||||||
|
detectedVariants: [],
|
||||||
|
arabicRatio: round(arabicRatio, 3),
|
||||||
|
reason: 'target is not an Arabic-script language',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text is Arabic-script AND target is Arabic-script: check the variant.
|
||||||
|
const detected = new Set<string>();
|
||||||
|
for (const [langCode, chars] of Object.entries(DISCRIMINATING_CHARS)) {
|
||||||
|
if (chars.size === 0) continue;
|
||||||
|
for (const ch of text) {
|
||||||
|
if (chars.has(ch)) {
|
||||||
|
detected.add(langCode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detected.size > 0 && claimedLang && !detected.has(claimedLang.toLowerCase())) {
|
||||||
|
return {
|
||||||
|
verdict: 'fail',
|
||||||
|
claimedLang,
|
||||||
|
detectedVariants: Array.from(detected).sort(),
|
||||||
|
arabicRatio: round(arabicRatio, 3),
|
||||||
|
reason: `target=${claimedLang} but text contains characters typical of ${Array.from(detected).sort().join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
verdict: 'pass',
|
||||||
|
claimedLang,
|
||||||
|
detectedVariants: detected.size > 0 ? Array.from(detected).sort() : (claimedLang ? [claimedLang] : []),
|
||||||
|
arabicRatio: round(arabicRatio, 3),
|
||||||
|
reason: 'ok',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Length check ----------
|
||||||
|
|
||||||
|
const RATIO_MAX = 3.5;
|
||||||
|
const RATIO_MIN = 0.15;
|
||||||
|
const ABSOLUTE_MIN_LENGTH = 2;
|
||||||
|
const MIN_SOURCE_LENGTH_FOR_RATIO = 20;
|
||||||
|
|
||||||
|
export interface LengthCheckResult {
|
||||||
|
issue: string | null;
|
||||||
|
ratio: number | null;
|
||||||
|
sourceLength: number;
|
||||||
|
translatedLength: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lengthCheck(sourceText: string, translatedText: string): LengthCheckResult {
|
||||||
|
if (!sourceText) {
|
||||||
|
return {
|
||||||
|
issue: null,
|
||||||
|
ratio: null,
|
||||||
|
sourceLength: 0,
|
||||||
|
translatedLength: (translatedText || '').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcLen = sourceText.trim().length;
|
||||||
|
const transLen = (translatedText || '').trim().length;
|
||||||
|
|
||||||
|
if (transLen === 0) {
|
||||||
|
return {
|
||||||
|
issue: 'truncation_suspect',
|
||||||
|
ratio: 0,
|
||||||
|
sourceLength: srcLen,
|
||||||
|
translatedLength: transLen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMostlyNumeric(sourceText)) {
|
||||||
|
return {
|
||||||
|
issue: null,
|
||||||
|
ratio: null,
|
||||||
|
sourceLength: srcLen,
|
||||||
|
translatedLength: transLen,
|
||||||
|
note: 'skipped: numeric source',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srcLen < MIN_SOURCE_LENGTH_FOR_RATIO) {
|
||||||
|
return {
|
||||||
|
issue: null,
|
||||||
|
ratio: null,
|
||||||
|
sourceLength: srcLen,
|
||||||
|
translatedLength: transLen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = transLen / srcLen;
|
||||||
|
|
||||||
|
if (ratio > RATIO_MAX) {
|
||||||
|
return {
|
||||||
|
issue: 'length_outlier',
|
||||||
|
ratio: round(ratio, 2),
|
||||||
|
sourceLength: srcLen,
|
||||||
|
translatedLength: transLen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ratio < RATIO_MIN) {
|
||||||
|
return {
|
||||||
|
issue: 'truncation_suspect',
|
||||||
|
ratio: round(ratio, 2),
|
||||||
|
sourceLength: srcLen,
|
||||||
|
translatedLength: transLen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
issue: null,
|
||||||
|
ratio: round(ratio, 2),
|
||||||
|
sourceLength: srcLen,
|
||||||
|
translatedLength: transLen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMostlyNumeric(text: string): boolean {
|
||||||
|
if (!text) return false;
|
||||||
|
const chars: string[] = [];
|
||||||
|
for (const ch of text) {
|
||||||
|
if (!/\s/.test(ch)) chars.push(ch);
|
||||||
|
}
|
||||||
|
if (chars.length === 0) return false;
|
||||||
|
const digitCount = chars.filter((c) => /\d/.test(c)).length;
|
||||||
|
return digitCount / chars.length >= 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Pattern leak / repetition ----------
|
||||||
|
|
||||||
|
const LEAK_PREFIX_PATTERNS: RegExp[] = [
|
||||||
|
/^(translation|translated text|here is the translation|here'?s the translation)\s*[::-]/i,
|
||||||
|
/^(voici (la |ma )?traduction|traduction\s*[::-])\b/i,
|
||||||
|
/^(原文|译|翻译|译为|以下是)\s*[::]?/u,
|
||||||
|
/^(sure,?\s+here'?s?\s+(the\s+)?translation|of course,?\s+here)/i,
|
||||||
|
/^(\*\*|__|#)\s*translation/i,
|
||||||
|
/^translated from\s+\w+\s+to\s+\w+\s*[::-]/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const REPETITION_THRESHOLD = 5;
|
||||||
|
const CHAR_REPETITION_THRESHOLD = 20;
|
||||||
|
|
||||||
|
export interface PatternCheckResult {
|
||||||
|
issue: string | null;
|
||||||
|
matchedPattern: string | null;
|
||||||
|
repetitionCount: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patternCheck(text: string): PatternCheckResult {
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
return { issue: null, matchedPattern: null, repetitionCount: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped = text.trimStart();
|
||||||
|
|
||||||
|
// 1. Prompt leak
|
||||||
|
for (const pat of LEAK_PREFIX_PATTERNS) {
|
||||||
|
if (pat.test(stripped)) {
|
||||||
|
return {
|
||||||
|
issue: 'prompt_leak',
|
||||||
|
matchedPattern: pat.source,
|
||||||
|
repetitionCount: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Token-level repetition
|
||||||
|
const tokens = stripped.split(/\s+/).filter((t) => t.length > 0);
|
||||||
|
const tokenRep = maxConsecutiveTokenRepetition(tokens);
|
||||||
|
if (tokenRep >= REPETITION_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
issue: 'repetition_hallucination',
|
||||||
|
matchedPattern: null,
|
||||||
|
repetitionCount: tokenRep,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Character-level repetition
|
||||||
|
const charRep = maxConsecutiveCharRepetition(stripped);
|
||||||
|
if (charRep >= CHAR_REPETITION_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
issue: 'repetition_hallucination',
|
||||||
|
matchedPattern: null,
|
||||||
|
repetitionCount: charRep,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
issue: null,
|
||||||
|
matchedPattern: null,
|
||||||
|
repetitionCount: Math.max(tokenRep, charRep) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxConsecutiveTokenRepetition(tokens: string[]): number {
|
||||||
|
if (tokens.length === 0) return 0;
|
||||||
|
const norm = tokens.map((t) => t.toLowerCase().replace(/[.,!?;:"'`()\[\]{}]/g, ''));
|
||||||
|
let maxRun = 1;
|
||||||
|
let currentRun = 1;
|
||||||
|
for (let i = 1; i < norm.length; i++) {
|
||||||
|
if (norm[i] && norm[i] === norm[i - 1]) {
|
||||||
|
currentRun++;
|
||||||
|
if (currentRun > maxRun) maxRun = currentRun;
|
||||||
|
} else {
|
||||||
|
currentRun = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxConsecutiveCharRepetition(text: string): number {
|
||||||
|
if (!text) return 0;
|
||||||
|
let maxRun = 1;
|
||||||
|
let currentRun = 1;
|
||||||
|
for (let i = 1; i < text.length; i++) {
|
||||||
|
if (text[i] === text[i - 1] && !/\s/.test(text[i])) {
|
||||||
|
currentRun++;
|
||||||
|
if (currentRun > maxRun) maxRun = currentRun;
|
||||||
|
} else {
|
||||||
|
currentRun = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Heuristic actual-script detection (for diagnostics) ----------
|
||||||
|
|
||||||
|
const SCRIPT_DETECTION_ORDER: readonly string[] = [
|
||||||
|
'hiragana_katakana', 'hangul', 'cjk', 'thai', 'lao', 'burmese',
|
||||||
|
'khmer', 'devanagari', 'bengali', 'tamil', 'telugu', 'kannada',
|
||||||
|
'malayalam', 'sinhala', 'gujarati', 'gurmukhi',
|
||||||
|
'arabic', 'hebrew', 'cyrillic', 'greek', 'armenian', 'georgian',
|
||||||
|
'ethiopic', 'tibetan', 'thaana',
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectActualScript(text: string): string {
|
||||||
|
const letters = countLetters(text);
|
||||||
|
if (letters === 0) return 'unknown';
|
||||||
|
for (const scriptId of SCRIPT_DETECTION_ORDER) {
|
||||||
|
const ranges = getRanges(scriptId);
|
||||||
|
const inScript = countInScript(text, ranges);
|
||||||
|
if (inScript / letters > 0.4) return scriptId;
|
||||||
|
}
|
||||||
|
return 'latin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Main per-chunk evaluation ----------
|
||||||
|
|
||||||
|
export function evaluateChunk(
|
||||||
|
sourceText: string,
|
||||||
|
translatedText: string | null | undefined,
|
||||||
|
targetLang: string | null | undefined,
|
||||||
|
): QualityCheckResult {
|
||||||
|
if (translatedText === null || translatedText === undefined) {
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
score: 0,
|
||||||
|
issues: ['empty_translation'],
|
||||||
|
detectedScript: null,
|
||||||
|
expectedScript: null,
|
||||||
|
details: { reason: 'translation is null/undefined' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = translatedText.trim();
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
score: 0,
|
||||||
|
issues: ['empty_translation'],
|
||||||
|
detectedScript: null,
|
||||||
|
expectedScript: null,
|
||||||
|
details: { reason: 'translation is empty or whitespace-only' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLangLower = (targetLang || '').toLowerCase() || null;
|
||||||
|
const issues: string[] = [];
|
||||||
|
const details: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// --- Script detection ---
|
||||||
|
const expectedScript = getScript(targetLangLower);
|
||||||
|
const expectedRanges = getRanges(expectedScript);
|
||||||
|
const letters = countLetters(text);
|
||||||
|
|
||||||
|
let scriptScore: number;
|
||||||
|
let detectedScript: string | null;
|
||||||
|
|
||||||
|
if (letters === 0) {
|
||||||
|
scriptScore = 1.0;
|
||||||
|
detectedScript = expectedScript;
|
||||||
|
details.script_check = 'skipped: no alphabetic characters';
|
||||||
|
} else {
|
||||||
|
detectedScript = detectActualScript(text);
|
||||||
|
const inExpected = countInScript(text, expectedRanges);
|
||||||
|
scriptScore = inExpected / letters;
|
||||||
|
|
||||||
|
details.script_score = round(scriptScore, 3);
|
||||||
|
details.letters_in_text = letters;
|
||||||
|
details.letters_in_script = inExpected;
|
||||||
|
details.detected_script = detectedScript;
|
||||||
|
details.expected_script = expectedScript;
|
||||||
|
details.min_ratio = MIN_RATIO_IN_SCRIPT;
|
||||||
|
|
||||||
|
if (expectedScript !== 'latin' && expectedRanges.length > 0) {
|
||||||
|
// Specific non-Latin target.
|
||||||
|
if (scriptScore < MIN_RATIO_IN_SCRIPT) {
|
||||||
|
issues.push('wrong_script');
|
||||||
|
details.reason = `only ${Math.round(scriptScore * 100)}% of letters match ${expectedScript} script; text appears to be in ${detectedScript}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Latin target. If detected script is clearly non-Latin, fail.
|
||||||
|
if (detectedScript && detectedScript !== 'latin' && detectedScript !== 'unknown') {
|
||||||
|
const nonLatinRanges = getRanges(detectedScript);
|
||||||
|
const inDetected = countInScript(text, nonLatinRanges);
|
||||||
|
const nonLatinConfidence = inDetected / letters;
|
||||||
|
if (nonLatinConfidence >= 0.7) {
|
||||||
|
issues.push('wrong_script');
|
||||||
|
details.reason = `target is Latin but ${Math.round(nonLatinConfidence * 100)}% of letters are in ${detectedScript} script — language confusion`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Arabic-script variant detection ---
|
||||||
|
if (isArabicScriptLang(targetLangLower)) {
|
||||||
|
const variantResult = detectArabicVariant(text, targetLangLower);
|
||||||
|
details.arabic_variant = variantResult;
|
||||||
|
if (variantResult.verdict === 'fail') {
|
||||||
|
issues.push('wrong_arabic_variant');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Length sanity ---
|
||||||
|
const lengthResult = lengthCheck(sourceText, text);
|
||||||
|
details.length = lengthResult;
|
||||||
|
if (lengthResult.issue) {
|
||||||
|
issues.push(lengthResult.issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pattern leak / repetition ---
|
||||||
|
const leakResult = patternCheck(text);
|
||||||
|
details.pattern_check = leakResult;
|
||||||
|
if (leakResult.issue) {
|
||||||
|
issues.push(leakResult.issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Aggregate ---
|
||||||
|
const passed = issues.length === 0;
|
||||||
|
const nChecks = 3;
|
||||||
|
const nFailed = issues.filter((issue) =>
|
||||||
|
['wrong_script', 'wrong_arabic_variant', 'length_outlier', 'truncation_suspect', 'prompt_leak', 'repetition_hallucination'].includes(issue),
|
||||||
|
).length;
|
||||||
|
const score = Math.max(0, 1 - nFailed / nChecks);
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed,
|
||||||
|
score: round(score, 3),
|
||||||
|
issues,
|
||||||
|
detectedScript,
|
||||||
|
expectedScript,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Document-level aggregation ----------
|
||||||
|
|
||||||
|
export function evaluateDocument(
|
||||||
|
sourceChunks: string[],
|
||||||
|
translatedChunks: string[],
|
||||||
|
targetLang: string | null | undefined,
|
||||||
|
sampleSize: number = 50,
|
||||||
|
): DocumentQualityResult {
|
||||||
|
const n = Math.min(sourceChunks.length, translatedChunks.length);
|
||||||
|
const chunkResults: QualityCheckResult[] = [];
|
||||||
|
const issuesCount: Record<string, number> = {};
|
||||||
|
const samples: DocumentQualityResult['samples'] = [];
|
||||||
|
let scoreSum = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const r = evaluateChunk(sourceChunks[i], translatedChunks[i], targetLang);
|
||||||
|
chunkResults.push(r);
|
||||||
|
scoreSum += r.score;
|
||||||
|
for (const issue of r.issues) {
|
||||||
|
issuesCount[issue] = (issuesCount[issue] || 0) + 1;
|
||||||
|
}
|
||||||
|
if (!r.passed) {
|
||||||
|
failedCount++;
|
||||||
|
if (samples.length < sampleSize) {
|
||||||
|
samples.push({
|
||||||
|
index: i,
|
||||||
|
issues: r.issues,
|
||||||
|
sourcePreview: (sourceChunks[i] || '').slice(0, 80),
|
||||||
|
translatedPreview: (translatedChunks[i] || '').slice(0, 80),
|
||||||
|
details: r.details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meanScore = n > 0 ? scoreSum / n : 0;
|
||||||
|
const passed = failedCount === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed,
|
||||||
|
score: round(meanScore, 3),
|
||||||
|
chunkCount: n,
|
||||||
|
failedChunkCount: failedCount,
|
||||||
|
issues: issuesCount,
|
||||||
|
samples,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Utilities ----------
|
||||||
|
|
||||||
|
function round(value: number, decimals: number): number {
|
||||||
|
const factor = 10 ** decimals;
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
/**
|
||||||
|
* Tests for src/utils/scriptDetector.ts
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* npx tsx --test tests/utils/scriptDetector.test.ts
|
||||||
|
*
|
||||||
|
* Or in package.json scripts:
|
||||||
|
* "test:quality": "tsx --test tests/utils/scriptDetector.test.ts"
|
||||||
|
*/
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getScript,
|
||||||
|
isArabicScriptLang,
|
||||||
|
evaluateChunk,
|
||||||
|
evaluateDocument,
|
||||||
|
detectArabicVariant,
|
||||||
|
lengthCheck,
|
||||||
|
patternCheck,
|
||||||
|
} from '../../src/utils/scriptDetector.ts';
|
||||||
|
|
||||||
|
describe('getScript', () => {
|
||||||
|
it('maps cyrillic languages', () => {
|
||||||
|
assert.equal(getScript('ru'), 'cyrillic');
|
||||||
|
assert.equal(getScript('uk'), 'cyrillic');
|
||||||
|
assert.equal(getScript('be'), 'cyrillic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps latin languages', () => {
|
||||||
|
assert.equal(getScript('en'), 'latin');
|
||||||
|
assert.equal(getScript('fr'), 'latin');
|
||||||
|
assert.equal(getScript('de'), 'latin');
|
||||||
|
assert.equal(getScript('vi'), 'latin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps CJK languages', () => {
|
||||||
|
assert.equal(getScript('zh'), 'cjk');
|
||||||
|
assert.equal(getScript('zh-cn'), 'cjk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Japanese to hiragana_katakana', () => {
|
||||||
|
assert.equal(getScript('ja'), 'hiragana_katakana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Korean to hangul', () => {
|
||||||
|
assert.equal(getScript('ko'), 'hangul');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Yiddish to hebrew (not arabic)', () => {
|
||||||
|
assert.equal(getScript('yi'), 'hebrew');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Maldivian to thaana (not arabic)', () => {
|
||||||
|
assert.equal(getScript('dv'), 'thaana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to latin for unknown', () => {
|
||||||
|
assert.equal(getScript('xx'), 'latin');
|
||||||
|
assert.equal(getScript(''), 'latin');
|
||||||
|
assert.equal(getScript(null), 'latin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
assert.equal(getScript('FR'), 'latin');
|
||||||
|
assert.equal(getScript('ZH-CN'), 'cjk');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isArabicScriptLang', () => {
|
||||||
|
it('returns true for Arabic-script langs', () => {
|
||||||
|
assert.equal(isArabicScriptLang('ar'), true);
|
||||||
|
assert.equal(isArabicScriptLang('fa'), true);
|
||||||
|
assert.equal(isArabicScriptLang('ur'), true);
|
||||||
|
assert.equal(isArabicScriptLang('ckb'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-Arabic langs', () => {
|
||||||
|
assert.equal(isArabicScriptLang('fr'), false);
|
||||||
|
assert.equal(isArabicScriptLang('he'), false);
|
||||||
|
assert.equal(isArabicScriptLang('en'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Happy path ----------
|
||||||
|
|
||||||
|
describe('evaluateChunk — correct script', () => {
|
||||||
|
it('russian', () => {
|
||||||
|
const r = evaluateChunk('Hello world', 'Привет мир', 'ru');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
assert.ok(!r.issues.includes('wrong_script'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chinese', () => {
|
||||||
|
const r = evaluateChunk('Hello', '你好世界', 'zh');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arabic', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'مرحبا بالعالم', 'ar');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persian (with unique chars)', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'سلام چطوری؟ من پژوهشگر هستم', 'fa');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('french', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'Bonjour le monde', 'fr');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hebrew', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'שלום עולם', 'he');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('korean', () => {
|
||||||
|
const r = evaluateChunk('Hello', '안녕하세요 세계', 'ko');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('japanese', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'こんにちは世界', 'ja');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hindi', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'नमस्ते दुनिया', 'hi');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thai', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'สวัสดีชาวโลก', 'th');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('greek', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'Γεια σας κόσμε', 'el');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Wrong script detection ----------
|
||||||
|
|
||||||
|
describe('evaluateChunk — wrong script', () => {
|
||||||
|
it('french text for japanese target', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'Bonjour le monde', 'ja');
|
||||||
|
assert.equal(r.passed, false);
|
||||||
|
assert.ok(r.issues.includes('wrong_script'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arabic text for french target (language confusion)', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'مرحبا بالعالم', 'fr');
|
||||||
|
assert.equal(r.passed, false);
|
||||||
|
assert.ok(r.issues.includes('wrong_script'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('russian text for english target', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'Привет мир', 'en');
|
||||||
|
assert.equal(r.passed, false);
|
||||||
|
assert.ok(r.issues.includes('wrong_script'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chinese text for korean target', () => {
|
||||||
|
const r = evaluateChunk('Hello', '你好世界', 'ko');
|
||||||
|
assert.equal(r.passed, false);
|
||||||
|
assert.ok(r.issues.includes('wrong_script'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Arabic variant discrimination ----------
|
||||||
|
|
||||||
|
describe('evaluateChunk — Arabic variant discrimination', () => {
|
||||||
|
it('persian text for arabic target fails', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'سلام چطوری؟', 'ar');
|
||||||
|
assert.equal(r.passed, false);
|
||||||
|
assert.ok(r.issues.includes('wrong_arabic_variant'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('urdu text for persian target fails', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'السلام ٹڈ', 'fa');
|
||||||
|
assert.equal(r.passed, false);
|
||||||
|
assert.ok(r.issues.includes('wrong_arabic_variant'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pashto text for arabic target fails', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'السلام ټډړ', 'ar');
|
||||||
|
assert.equal(r.passed, false);
|
||||||
|
assert.ok(r.issues.includes('wrong_arabic_variant'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Edge cases ----------
|
||||||
|
|
||||||
|
describe('evaluateChunk — edge cases', () => {
|
||||||
|
it('empty translation passes (skipped)', () => {
|
||||||
|
const r = evaluateChunk('Hello', '', 'fr');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
assert.ok(r.issues.includes('empty_translation'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whitespace-only translation passes', () => {
|
||||||
|
const r = evaluateChunk('Hello', ' \n ', 'fr');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null translation passes', () => {
|
||||||
|
const r = evaluateChunk('Hello', null, 'fr');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undefined translation passes', () => {
|
||||||
|
const r = evaluateChunk('Hello', undefined, 'fr');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers only pass for fr target', () => {
|
||||||
|
const r = evaluateChunk('Price: 100', '100', 'fr');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown target lang falls back to latin', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'Bonjour', 'xx');
|
||||||
|
assert.equal(r.passed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Length ----------
|
||||||
|
|
||||||
|
describe('evaluateChunk — length', () => {
|
||||||
|
it('huge translation flagged via repetition (source too short)', () => {
|
||||||
|
const r = evaluateChunk('Short', 'x'.repeat(1000), 'fr');
|
||||||
|
assert.ok(r.issues.includes('repetition_hallucination'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('huge translation with long source flagged as length', () => {
|
||||||
|
const src = 'A'.repeat(200);
|
||||||
|
const r = evaluateChunk(src, 'x'.repeat(1000), 'fr');
|
||||||
|
assert.ok(r.issues.includes('length_outlier'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tiny translation flagged', () => {
|
||||||
|
const r = evaluateChunk('A'.repeat(100), 'ok', 'fr');
|
||||||
|
assert.ok(r.issues.includes('truncation_suspect'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numeric source skips length check', () => {
|
||||||
|
const r = evaluateChunk('Price: 100', '100', 'fr');
|
||||||
|
assert.ok(!r.issues.includes('truncation_suspect'));
|
||||||
|
assert.ok(!r.issues.includes('length_outlier'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Pattern leak / repetition ----------
|
||||||
|
|
||||||
|
describe('evaluateChunk — pattern leak', () => {
|
||||||
|
it('english prompt leak', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'Translation: Bonjour le monde', 'fr');
|
||||||
|
assert.ok(r.issues.includes('prompt_leak'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('french prompt leak', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'Voici la traduction : Bonjour', 'fr');
|
||||||
|
assert.ok(r.issues.includes('prompt_leak'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chinese prompt leak', () => {
|
||||||
|
const r = evaluateChunk('Hello', '翻译:你好', 'zh');
|
||||||
|
assert.ok(r.issues.includes('prompt_leak'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('token repetition detected', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'the the the the the the the', 'fr');
|
||||||
|
assert.ok(r.issues.includes('repetition_hallucination'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('char repetition detected', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'x'.repeat(30), 'fr');
|
||||||
|
assert.ok(r.issues.includes('repetition_hallucination'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normal text has no leak', () => {
|
||||||
|
const r = evaluateChunk('Hello', 'Bonjour le monde', 'fr');
|
||||||
|
assert.ok(!r.issues.includes('prompt_leak'));
|
||||||
|
assert.ok(!r.issues.includes('repetition_hallucination'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Document-level ----------
|
||||||
|
|
||||||
|
describe('evaluateDocument', () => {
|
||||||
|
it('all good', () => {
|
||||||
|
const result = evaluateDocument(
|
||||||
|
['Hello', 'World', 'Good morning'],
|
||||||
|
['Bonjour', 'Monde', 'Bonjour'],
|
||||||
|
'fr',
|
||||||
|
);
|
||||||
|
assert.equal(result.passed, true);
|
||||||
|
assert.equal(result.chunkCount, 3);
|
||||||
|
assert.equal(result.failedChunkCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('some failures', () => {
|
||||||
|
const result = evaluateDocument(
|
||||||
|
['Hello', 'World', 'Good morning'],
|
||||||
|
['Bonjour', 'مرحبا', 'Bonjour'],
|
||||||
|
'fr',
|
||||||
|
);
|
||||||
|
assert.equal(result.passed, false);
|
||||||
|
assert.equal(result.failedChunkCount, 1);
|
||||||
|
assert.ok('wrong_script' in result.issues);
|
||||||
|
assert.equal(result.samples.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty lists', () => {
|
||||||
|
const result = evaluateDocument([], [], 'fr');
|
||||||
|
assert.equal(result.passed, true);
|
||||||
|
assert.equal(result.chunkCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sample size caps samples', () => {
|
||||||
|
const result = evaluateDocument(
|
||||||
|
Array(10).fill('hi'),
|
||||||
|
Array(10).fill('مرحبا'),
|
||||||
|
'fr',
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
assert.equal(result.failedChunkCount, 10);
|
||||||
|
assert.equal(result.samples.length, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- detectArabicVariant direct ----------
|
||||||
|
|
||||||
|
describe('detectArabicVariant', () => {
|
||||||
|
it('persian for persian passes', () => {
|
||||||
|
const r = detectArabicVariant('سلام چطوری', 'fa');
|
||||||
|
assert.equal(r.verdict, 'pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persian for arabic fails', () => {
|
||||||
|
const r = detectArabicVariant('سلام چطوری', 'ar');
|
||||||
|
assert.equal(r.verdict, 'fail');
|
||||||
|
assert.ok(r.detectedVariants.includes('fa'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('urdu for persian fails', () => {
|
||||||
|
const r = detectArabicVariant('السلام ٹڈ', 'fa');
|
||||||
|
assert.equal(r.verdict, 'fail');
|
||||||
|
assert.ok(r.detectedVariants.includes('ur'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-arabic text skipped', () => {
|
||||||
|
const r = detectArabicVariant('Bonjour le monde', 'fa');
|
||||||
|
assert.equal(r.verdict, 'skip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- lengthCheck direct ----------
|
||||||
|
|
||||||
|
describe('lengthCheck', () => {
|
||||||
|
it('normal length returns ratio', () => {
|
||||||
|
const r = lengthCheck(
|
||||||
|
'Hello world this is a longer source string',
|
||||||
|
'Bonjour le monde ceci est une traduction francaise',
|
||||||
|
);
|
||||||
|
assert.equal(r.issue, null);
|
||||||
|
assert.ok(r.ratio !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('huge translation', () => {
|
||||||
|
const r = lengthCheck('A'.repeat(200), 'x'.repeat(1000));
|
||||||
|
assert.equal(r.issue, 'length_outlier');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tiny translation', () => {
|
||||||
|
const r = lengthCheck('A'.repeat(100), 'ok');
|
||||||
|
assert.equal(r.issue, 'truncation_suspect');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty translation flagged', () => {
|
||||||
|
const r = lengthCheck('Hello world this is a test', '');
|
||||||
|
assert.equal(r.issue, 'truncation_suspect');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- patternCheck direct ----------
|
||||||
|
|
||||||
|
describe('patternCheck', () => {
|
||||||
|
it('no leak on normal text', () => {
|
||||||
|
const r = patternCheck('Bonjour le monde');
|
||||||
|
assert.equal(r.issue, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('english leak', () => {
|
||||||
|
const r = patternCheck('Translation: Bonjour');
|
||||||
|
assert.equal(r.issue, 'prompt_leak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('french leak', () => {
|
||||||
|
const r = patternCheck('Voici la traduction : Bonjour');
|
||||||
|
assert.equal(r.issue, 'prompt_leak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chinese leak', () => {
|
||||||
|
const r = patternCheck('翻译:你好');
|
||||||
|
assert.equal(r.issue, 'prompt_leak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('token repetition', () => {
|
||||||
|
const r = patternCheck('the the the the the the the');
|
||||||
|
assert.equal(r.issue, 'repetition_hallucination');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('char repetition', () => {
|
||||||
|
const r = patternCheck('x'.repeat(30));
|
||||||
|
assert.equal(r.issue, 'repetition_hallucination');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user