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.
422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|