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:
@@ -8,7 +8,8 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"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": {
|
||||
"@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