From 815c30f4bed80bf269d0b13951c587dc6fd01f41 Mon Sep 17 00:00:00 2001 From: Haroon Yasin Date: Thu, 20 Aug 2026 00:04:57 +0500 Subject: [PATCH] fix(audio): stop Soniox writing Urdu in Devanagari, and never render it if it slips through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering the question directly: Soniox is NOT returning an Urdu-English label. On a real prod session (2026-08-19) it returned 4,926 tokens tagged `hi`, 139 tagged `en`, and NOT ONE tagged `ur`. The session-level label it stored was 'en' — the minority language of its own token counts. Across the fleet the labels have been 'en', 'hindi', 'javanese' and 'sindhi' on Urdu classroom audio, and 67 sessions since 2026-08-11 came back written in Devanagari. That script reaches a coach in the FICO evidence box (HITL R62/R63/R64, with a screenshot) and a teacher in the report. It cannot be drawn: there is no Devanagari font in bot/shared/fonts/ and the render container has no system fonts, so unlike the Urdu tofu of bd-osmk0 this one cannot be fixed by naming a fallback face. Three layers, cheapest first. LAYER 1 — stop inviting it. language_hints was a hardcoded ['en','ur','es','ar','pa','ta']: six languages, four of which this deployment does not serve. Hints only BIAS Soniox's language identification, they do not restrict it, and a wide list widens the search — Urdu and Hindi are the same spoken language, so it settled on Hindi. The default now comes from LANGUAGE_OFFER (ur, en), the single source of truth for what we serve, rather than a second hardcoded list that can drift from it. Callers passing an explicit language (reading assessment) take the single-hint branch and are untouched. LAYER 2 — if it arrives anyway, re-transcribe once with a single forced `ur` hint, which leaves the identifier no room to choose Hindi. Only attempted when the caller left the language open; a caller that already pinned one would get the identical answer back. LAYER 3 — the guarantee. If it STILL arrives, transliterate to Perso-Arabic so nothing in Devanagari can ever reach a rendered surface. The language label is overwritten to 'ur' at the same time: it was part of the same wrong answer, and resolveReportLanguage reads that field to choose the report's script branch, so leaving it would send an Urdu report down the Latin arm. Layer 3 is LOSSY and says so loudly — every layer logs at level='error'. Urdu does not write short vowels, श/ष both fold to ش, ن absorbs ण, and Arabic-origin spellings are phonetic (تریکے, not طریقے). It is a legibility rescue, not a transliterator with a lexicon: a coach sees words instead of boxes. The positional rules matter and are tested — geminates collapse (بچوں not بچچوں), aspirate geminates too (اچھا not اچچھا), ے is word-final while ی is medial (میں not مےں), and a medial independent vowel takes a hamza carrier (بتائیے not بتاااے). Tests: 38 new, including a mutation pass that breaks each guard and confirms the matcher goes red. Comments are stripped before any source assertion (language-protocol §7.1). Coaching suite: 12 failing suites before and after, zero new; +38 passing. Refs: bd-bfy69 Co-Authored-By: Claude Opus 5 --- bot/shared/services/audio.service.js | 92 +++++++- bot/shared/utils/devanagari-guard.js | 207 ++++++++++++++++++ tests/coaching/devanagari-guard.test.js | 145 ++++++++++++ .../devanagari-transcribe-recovery.test.js | 118 ++++++++++ 4 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 bot/shared/utils/devanagari-guard.js create mode 100644 tests/coaching/devanagari-guard.test.js create mode 100644 tests/coaching/devanagari-transcribe-recovery.test.js diff --git a/bot/shared/services/audio.service.js b/bot/shared/services/audio.service.js index 8215d411..8de66a8a 100644 --- a/bot/shared/services/audio.service.js +++ b/bot/shared/services/audio.service.js @@ -103,7 +103,25 @@ class AudioService { // all supported languages (coaching multi-language audio). Soniox expects // ISO 639-1 codes (e.g. 'pa'), NOT locale codes ('pa-PK') — strip the suffix. const normalizedLanguage = language ? language.split('-')[0] : null; - const languageHints = normalizedLanguage ? [normalizedLanguage] : ['en', 'ur', 'es', 'ar', 'pa', 'ta']; + // bd-bfy69 — LAYER 1 of the Devanagari defence: stop inviting it. + // + // This list used to be ['en','ur','es','ar','pa','ta'] — six languages, four + // of which this deployment does not serve. Hints only BIAS Soniox's language + // identification; they do not restrict it, and a wide, mostly-irrelevant list + // widens the search. Urdu and Hindi are the same spoken language, so the + // identifier settled on Hindi and wrote the transcript in DEVANAGARI: one + // prod session came back with 4,926 tokens tagged `hi`, 139 tagged `en`, and + // not one tagged `ur` (2026-08-19). Coaches then read that script in the FICO + // evidence box, where it cannot even be drawn — we ship no Devanagari font. + // + // The default now comes from LANGUAGE_OFFER, the single source of truth for + // what this deployment serves, rather than a second hardcoded list that can + // drift from it (language-protocol: one offer source). + // + // Callers passing an explicit language (reading assessment) are unaffected — + // they take the single-hint branch above, exactly as before. + const { LANGUAGE_OFFER } = require('../config/languages'); + const languageHints = normalizedLanguage ? [normalizedLanguage] : [...LANGUAGE_OFFER]; const requestBody = { file_id: fileId, @@ -555,7 +573,79 @@ class AudioService { * @param {boolean} enableDiarization - Whether to enable speaker diarization (for classroom audio) * @returns {Promise} Transcription text */ + /** + * bd-bfy69 — LAYERS 2 AND 3 of the Devanagari defence. + * + * Layer 1 (the hints, in _buildSonioxRequestBody) makes Hindi much less + * likely, but hints only bias the language identifier; they cannot forbid an + * outcome. So this wrapper checks what actually came back: + * + * layer 2 — Devanagari present and the caller did not pin a language: + * transcribe once more with a single forced `ur` hint, which + * takes the single-language branch and leaves the identifier no + * room to choose Hindi. + * layer 3 — still Devanagari (or the retry failed, or the caller pinned a + * language so a retry would be pointless): transliterate to + * Perso-Arabic. Never return Devanagari to a caller. We ship no + * Devanagari font, so anything that reaches a report or a Flow in + * that script renders as empty boxes. + * + * Both layers log at level='error': reaching either means the language + * identifier is still getting Urdu wrong, and that is worth seeing. + * + * The unwrapped single attempt is `_transcribeOnce`. + */ static async transcribe(audioPath, enableDiarization = false, language = null) { + const { hasDevanagari, countDevanagari, ensureNoDevanagari } = require('../utils/devanagari-guard'); + + const result = await this._transcribeOnce(audioPath, enableDiarization, language); + if (!result || !hasDevanagari(result.text)) return result; + + logToFile('❌ Transcript returned in Devanagari — Urdu speech identified as Hindi', { + audioPath, + devanagariChars: countDevanagari(result.text), + sonioxLanguage: result.language, + callerLanguage: language || null, + willRetryAsUrdu: !language, + }, 'error'); + + // Layer 2 — only worth trying when the caller left the language open. If a + // caller already pinned one, Soniox was given a single hint and re-running + // the identical request would return the identical answer. + if (!language) { + try { + const retry = await this._transcribeOnce(audioPath, enableDiarization, 'ur'); + if (retry && retry.text && !hasDevanagari(retry.text)) { + logToFile('✅ Devanagari cleared by re-transcribing with a forced Urdu hint', { + audioPath, textLength: retry.text.length, + }); + return { ...retry, language: retry.language || 'ur', devanagariRetried: true }; + } + logToFile('❌ Forced-Urdu retry still returned Devanagari — falling back to transliteration', { + audioPath, devanagariChars: countDevanagari(retry && retry.text), + }, 'error'); + } catch (retryError) { + logToFile('❌ Forced-Urdu retry threw — falling back to transliteration', { + audioPath, error: retryError.message, + }, 'error'); + } + } + + // Layer 3 — the guarantee. Lossy, and deliberately loud about it. + const text = ensureNoDevanagari(result.text, { + onDetected: ({ count, sample }) => logToFile( + '❌ Transliterating Devanagari to Urdu script as a last resort — the transcript is readable but not verbatim', + { audioPath, devanagariChars: count, sample }, 'error', + ), + }); + // The language label was part of the same wrong answer: Soniox reported + // 'en'/'hindi' for speech we have now written in Urdu script. Downstream + // (resolveReportLanguage) picks the report's script from this field, so + // leaving the bad label would send an Urdu report down the Latin branch. + return { ...result, text, language: 'ur', devanagariTransliterated: true }; + } + + static async _transcribeOnce(audioPath, enableDiarization = false, language = null) { let fileId = null; try { diff --git a/bot/shared/utils/devanagari-guard.js b/bot/shared/utils/devanagari-guard.js new file mode 100644 index 00000000..edcf8db5 --- /dev/null +++ b/bot/shared/utils/devanagari-guard.js @@ -0,0 +1,207 @@ +/** + * bd-bfy69 — nothing we render may be in Devanagari. + * + * WHY THIS EXISTS + * Urdu and Hindi are the same spoken language in two scripts. Soniox's language + * identification hears an Urdu-medium ICT classroom and, on ~7% of recordings + * since 2026-08-11, decides it is Hindi and writes the transcript in Devanagari. + * Measured on prod: one session came back with 4,926 tokens tagged `hi` against + * 139 tagged `en`, and not a single `ur`. That script then flows into the FICO + * evidence a coach reads in the Flow, and into the teacher's report. + * + * It cannot be drawn. There is no Devanagari font in bot/shared/fonts/, and the + * render container has no system fonts, so every Devanagari glyph paints as an + * empty box. Unlike the Urdu tofu (bd-osmk0), this one cannot be fixed by + * naming a fallback face — we do not ship the face. + * + * THE ORDER OF DEFENCE (each layer is cheaper and better than the next) + * 1. Do not invite it: the Soniox language hints come from LANGUAGE_OFFER + * (ur, en) instead of a hardcoded six-language list. See audio.service.js. + * 2. If it arrives anyway: re-transcribe once with a single forced `ur` hint. + * 3. Only if it STILL arrives: transliterate to Perso-Arabic, here. + * + * HONEST LIMITS OF LAYER 3 — read before trusting its output. + * Devanagari→Urdu is lossy in both directions and this is a mechanical map, not + * a transliterator with a lexicon: + * - Urdu does not write short vowels, so ि and ु are dropped, exactly as a + * human would write them. A reader supplies them from context. + * - श and ष both fold to ش; ण and न both fold to ن. The distinction does not + * exist in Urdu orthography. + * - Nukta letters (क़ ख़ ग़ ज़ ड़ ढ़ फ़) are mapped, but Devanagari often omits the + * nukta, in which case ज़ arrives as ज and comes out as ج rather than ز. + * - Word-initial vowels are approximated; اِ / اُ distinctions are not restored. + * The result is READABLE Urdu, not correct Urdu. It exists so a coach sees words + * instead of boxes, and it always logs at error level so the real fix (layer 1 + * and 2) is never quietly replaced by this one. + */ + +// U+0900–U+097F, plus the Devanagari Extended and Vedic blocks so a stray +// character from either cannot slip past the detector. +const DEVANAGARI_RE = /[ऀ-ॿ꣠-ꣿ᳐-᳿]/; +const DEVANAGARI_GLOBAL_RE = /[ऀ-ॿ꣠-ꣿ᳐-᳿]/g; + +/** Two-character sequences first — a nukta or an aspirate must win over its base letter. */ +const DIGRAPHS = [ + ['क़', 'ق'], ['ख़', 'خ'], ['ग़', 'غ'], ['ज़', 'ز'], ['ड़', 'ڑ'], ['ढ़', 'ڑھ'], ['फ़', 'ف'], ['य़', 'ی'], + // The same letters when they arrive pre-composed rather than as base + U+093C. + ['क़', 'ق'], ['ख़', 'خ'], ['ग़', 'غ'], ['ज़', 'ز'], ['ड़', 'ڑ'], ['ढ़', 'ڑھ'], ['फ़', 'ف'], + // इए is the polite-imperative ending all over these debriefs — बताइए, कीजिए, + // दीजिए. Character-by-character it yields a double hamza (ئئے); Urdu writes ئیے. + ['इए', 'ئیے'], + // Same ending after a consonant, where the short-i matra is dropped: + // पूछिए, सुनिए, कीजिए. Urdu writes یے, not a hamza. + ['िए', 'یے'], +]; + +const CHARS = { + // Consonants. Aspirates carry the do-chashmi he (U+06BE), never the ordinary ہ. + 'क': 'ک', 'ख': 'کھ', 'ग': 'گ', 'घ': 'گھ', 'ङ': 'ن', + 'च': 'چ', 'छ': 'چھ', 'ज': 'ج', 'झ': 'جھ', 'ञ': 'ن', + 'ट': 'ٹ', 'ठ': 'ٹھ', 'ड': 'ڈ', 'ढ': 'ڈھ', 'ण': 'ن', + 'त': 'ت', 'थ': 'تھ', 'द': 'د', 'ध': 'دھ', 'न': 'ن', + 'प': 'پ', 'फ': 'پھ', 'ब': 'ب', 'भ': 'بھ', 'म': 'م', + 'य': 'ی', 'र': 'ر', 'ल': 'ل', 'व': 'و', 'ळ': 'ل', + 'श': 'ش', 'ष': 'ش', 'स': 'س', 'ह': 'ہ', + // Independent vowels. + 'अ': 'ا', 'आ': 'آ', 'इ': 'ا', 'ई': 'ای', 'उ': 'ا', 'ऊ': 'او', + 'ऋ': 'ر', 'ए': 'اے', 'ऐ': 'اے', 'ओ': 'او', 'औ': 'او', 'ऑ': 'آ', + // Dependent vowel signs. The short ones are intentionally dropped: Urdu does + // not write them, and inserting a letter for them produces nonsense. + 'ा': 'ا', 'ि': '', 'ी': 'ی', 'ु': '', 'ू': 'و', 'ृ': 'ر', + 'े': 'ے', 'ै': 'ے', 'ो': 'و', 'ौ': 'و', 'ॉ': 'ا', + // Signs. + 'ं': 'ں', 'ँ': 'ں', 'ः': 'ہ', + '्': '', // virama — suppresses the inherent vowel, which Urdu never wrote + '़': '', // bare nukta that survived the digraph pass + '।': '۔', '॥': '۔', 'ऽ': '', + // Devanagari digits fold to Latin: reports force numerals LTR anyway, and + // Urdu commonly uses Latin digits. + '०': '0', '१': '1', '२': '2', '३': '3', '४': '4', + '५': '5', '६': '6', '७': '7', '८': '8', '९': '9', +}; + +/** Does this text contain any Devanagari at all? */ +function hasDevanagari(text) { + return typeof text === 'string' && DEVANAGARI_RE.test(text); +} + +/** How many Devanagari code points are in here? Useful for logging severity. */ +function countDevanagari(text) { + if (typeof text !== 'string') return 0; + const m = text.match(DEVANAGARI_GLOBAL_RE); + return m ? m.length : 0; +} + +// Devanagari letters and signs that CONTINUE a word. Used to tell a medial +// position from a final one, which changes three mappings — see below. +const IN_WORD_RE = /[ऀ-ॿ]/; + +/** + * Position-sensitive mappings. Urdu spells the same Devanagari character + * differently depending on where in the word it falls, and ignoring that was + * the difference between "بتاااے" and "بتائے" on real transcripts. + */ +const MEDIAL_OVERRIDES = { + // ے is the word-FINAL shape of this vowel; medially Urdu writes ی. + // में -> میں, not مےں. हैं -> ہیں, not ہےں. + 'े': 'ی', 'ै': 'ی', +}; +/** + * An independent vowel inside a word takes a hamza carrier, not a bare alif. + * Without this, बताइए transliterates to بتاااے — three stacked alifs, which is + * not a word in any script. + */ +const POST_VOWEL_INDEPENDENTS = { + 'इ': 'ئ', 'ई': 'ئی', 'ए': 'ئے', 'ऐ': 'ئے', + 'उ': 'ؤ', 'ऊ': 'ؤ', 'ओ': 'ؤ', 'औ': 'ؤ', 'अ': '', 'आ': 'ا', +}; +/** Devanagari characters that leave the syllable "open", so a following independent vowel is medial. */ +const VOWELISH_RE = /[ािीुूृेैोौआअइईउऊएऐओौऔ]/; + +/** + * Collapse a geminate — consonant + virama + the SAME consonant. Devanagari + * writes बच्चों with a doubled च; Urdu marks it with a tashdid that is almost + * always left off, so the plain letter is what a reader expects: بچوں, not بچچوں. + */ +function collapseGeminates(src) { + // Only the plain consonant block: the nukta digraphs have already been + // rewritten to Perso-Arabic by the time this runs, and a geminate nukta + // letter does not occur in practice. + let out = src.replace(/([क-ह])्\1/g, '$1'); + // A geminated ASPIRATE is written unaspirated + aspirated in Devanagari — + // अच्छा is च ् छ, not छ ् छ — so the identical-letter rule above misses it. + // Urdu writes the aspirate once: اچھا, not اچچھا. + for (const [plain, asp] of [ + ['क', 'ख'], ['ग', 'घ'], ['च', 'छ'], ['ज', 'झ'], ['ट', 'ठ'], + ['ड', 'ढ'], ['त', 'थ'], ['द', 'ध'], ['प', 'फ'], ['ब', 'भ'], + ]) { + out = out.split(`${plain}्${asp}`).join(asp); + } + return out; +} + +/** + * Mechanically transliterate Devanagari runs to Perso-Arabic. Non-Devanagari + * characters — Latin, existing Urdu, digits, punctuation, whitespace — pass + * through untouched, so a code-switched transcript keeps its English words. + * + * Read the header before relying on the output: this is a legibility rescue. + */ +function transliterateToUrdu(text) { + if (typeof text !== 'string' || !text) return text; + let out = text; + for (const [from, to] of DIGRAPHS) out = out.split(from).join(to); + out = collapseGeminates(out); + + const chars = Array.from(out); + let result = ''; + for (let i = 0; i < chars.length; i++) { + const ch = chars[i]; + const prev = i > 0 ? chars[i - 1] : ''; + const next = i + 1 < chars.length ? chars[i + 1] : ''; + const medial = IN_WORD_RE.test(next); // another Devanagari char follows → not word-final + + if (medial && Object.prototype.hasOwnProperty.call(MEDIAL_OVERRIDES, ch)) { + result += MEDIAL_OVERRIDES[ch]; + continue; + } + // An independent vowel directly after a vowel or matra is medial: carry it + // on a hamza rather than opening a second alif. + if (VOWELISH_RE.test(prev) && Object.prototype.hasOwnProperty.call(POST_VOWEL_INDEPENDENTS, ch)) { + result += POST_VOWEL_INDEPENDENTS[ch]; + continue; + } + result += Object.prototype.hasOwnProperty.call(CHARS, ch) ? CHARS[ch] : ch; + } + // A virama or dropped short vowel can leave a doubled space; normalise. + return result.replace(/[ \t]{2,}/g, ' '); +} + +/** + * The guard itself. Returns the text unchanged when it is already clean, and + * the transliterated text when it is not — never Devanagari, whatever happens. + * + * @param {string} text + * @param {object} [opts] + * @param {function} [opts.onDetected] - called as ({ count, sample }) when the + * guard has to act. Wire it to a level='error' log: reaching layer 3 means + * layers 1 and 2 both failed and someone should look. + * @returns {string} + */ +function ensureNoDevanagari(text, opts = {}) { + if (!hasDevanagari(text)) return text; + const count = countDevanagari(text); + if (typeof opts.onDetected === 'function') { + const sample = (text.match(DEVANAGARI_GLOBAL_RE) || []).slice(0, 12).join(''); + try { opts.onDetected({ count, sample }); } catch (_) { /* logging must never break a transcript */ } + } + return transliterateToUrdu(text); +} + +module.exports = { + hasDevanagari, + countDevanagari, + transliterateToUrdu, + ensureNoDevanagari, + DEVANAGARI_RE, +}; diff --git a/tests/coaching/devanagari-guard.test.js b/tests/coaching/devanagari-guard.test.js new file mode 100644 index 00000000..1753a042 --- /dev/null +++ b/tests/coaching/devanagari-guard.test.js @@ -0,0 +1,145 @@ +/** + * bd-bfy69 — the script guard. Nothing we render may be in Devanagari. + * + * The strings below are REAL prod data: the first is the evidence quote a coach + * saw in the FICO Flow's B1 box on 2026-08-19 (HITL sheet R64's screenshot), the + * second is from a stored transcript on the same day. + */ + +const { + hasDevanagari, countDevanagari, transliterateToUrdu, ensureNoDevanagari, +} = require('../../bot/shared/utils/devanagari-guard'); + +// From the coach's screenshot, HITL R64. +const REAL_EVIDENCE = 'तो हमने तीन चीज़ों'; +// A code-switched line of the kind these transcripts are full of. +const REAL_MIXED = 'Okay, number 39. बारिश के बारे में बताइए, then write it in your copy.'; + +describe('bd-bfy69 — detection', () => { + it('spots Devanagari in the real evidence quote a coach was shown', () => { + expect(hasDevanagari(REAL_EVIDENCE)).toBe(true); + expect(countDevanagari(REAL_EVIDENCE)).toBeGreaterThan(10); + }); + + it('does not fire on Urdu, English, digits, or punctuation', () => { + for (const clean of [ + 'آپ نے بچوں کو بہت اچھا خوش آمدید کہا۔', + 'Ask one concise, reflective question.', + '74% · 110/148 · 2026-08-19', + '', ' ', 'ریاضی — Mathematics', + ]) { + expect(hasDevanagari(clean)).toBe(false); + expect(ensureNoDevanagari(clean)).toBe(clean); + } + }); + + it('is not fooled by a non-string', () => { + for (const v of [null, undefined, 42, {}, []]) { + expect(hasDevanagari(v)).toBe(false); + expect(countDevanagari(v)).toBe(0); + } + }); +}); + +describe('bd-bfy69 — the guarantee: no output may contain Devanagari', () => { + const CASES = [ + REAL_EVIDENCE, + REAL_MIXED, + 'क ख ग घ च छ ज झ ट ठ ड ढ त थ द ध न प फ ब भ म य र ल व श ष स ह', + 'क़ ख़ ग़ ज़ ड़ ढ़ फ़', // nukta letters + 'अ आ इ ई उ ऊ ए ऐ ओ औ', // independent vowels + 'का कि की कु कू के कै को कौ', // every matra + 'हिन्दी में लिखा हुआ वाक्य।', // virama + danda + '०१२३४५६७८९', // Devanagari digits + 'बच्चों ने कहा — "शाबाश!" और फिर 5 minutes.', + ]; + + it.each(CASES)('leaves no Devanagari behind in %p', (input) => { + const out = ensureNoDevanagari(input); + expect(hasDevanagari(out)).toBe(false); + expect(countDevanagari(out)).toBe(0); + }); + + it('is idempotent — guarding twice changes nothing the second time', () => { + for (const input of CASES) { + const once = ensureNoDevanagari(input); + expect(ensureNoDevanagari(once)).toBe(once); + } + }); + + it('produces something, not an empty string — stripping is NOT the fix', () => { + // Deleting the script would leave the coach with a blank evidence box, + // which is the bug we are fixing, not a fix for it. + const out = transliterateToUrdu(REAL_EVIDENCE); + expect(out.trim().length).toBeGreaterThan(5); + expect(out).toMatch(/[؀-ۿ]/); // it is Perso-Arabic now + }); +}); + +describe('bd-bfy69 — what must survive untouched', () => { + it('keeps the English half of a code-switched line word-for-word', () => { + const out = ensureNoDevanagari(REAL_MIXED); + expect(out).toContain('Okay, number 39.'); + expect(out).toContain('then write it in your copy.'); + }); + + it('keeps Latin digits, percentages and dates exactly as they were', () => { + const out = ensureNoDevanagari('बच्चे 5 में से 3, यानी 60% — 2026-08-19'); + expect(out).toContain('5'); + expect(out).toContain('3'); + expect(out).toContain('60%'); + expect(out).toContain('2026-08-19'); + }); + + it('folds Devanagari digits to Latin rather than leaving them undrawable', () => { + expect(ensureNoDevanagari('०१२३४५६७८९')).toBe('0123456789'); + }); + + it('maps aspirates onto do-chashmi he (U+06BE), not the ordinary ہ', () => { + // کھ, not کہ — an aspirate written with the wrong he reads as a different word. + expect(transliterateToUrdu('ख')).toBe('کھ'); + expect(transliterateToUrdu('घ')).toBe('گھ'); + expect(transliterateToUrdu('थ')).toBe('تھ'); + expect(transliterateToUrdu('ह')).toBe('ہ'); // plain ha stays the ordinary he + }); + + it('maps the retroflex series to the Urdu retroflex letters', () => { + expect(transliterateToUrdu('ट')).toBe('ٹ'); + expect(transliterateToUrdu('ड')).toBe('ڈ'); + expect(transliterateToUrdu('ड़')).toBe('ڑ'); + }); + + it('turns the danda into an Urdu full stop', () => { + expect(transliterateToUrdu('।')).toBe('۔'); + }); + + it('drops the short-vowel matras, as Urdu orthography does', () => { + // कि -> ک (no letter for the short i), whereas की -> کی + expect(transliterateToUrdu('कि')).toBe('ک'); + expect(transliterateToUrdu('की')).toBe('کی'); + }); +}); + +describe('bd-bfy69 — the guard reports itself', () => { + it('calls onDetected with a count and a sample when it has to act', () => { + const seen = []; + ensureNoDevanagari(REAL_EVIDENCE, { onDetected: (info) => seen.push(info) }); + expect(seen).toHaveLength(1); + expect(seen[0].count).toBeGreaterThan(10); + expect(hasDevanagari(seen[0].sample)).toBe(true); + }); + + it('stays silent on clean text', () => { + const seen = []; + ensureNoDevanagari('آپ نے اچھا پڑھایا', { onDetected: () => seen.push(1) }); + expect(seen).toHaveLength(0); + }); + + it('still returns clean text if the reporter throws — logging must never eat a transcript', () => { + const out = ensureNoDevanagari(REAL_EVIDENCE, { + onDetected: () => { throw new Error('logger exploded'); }, + }); + expect(hasDevanagari(out)).toBe(false); + expect(out.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/coaching/devanagari-transcribe-recovery.test.js b/tests/coaching/devanagari-transcribe-recovery.test.js new file mode 100644 index 00000000..7d2a02f0 --- /dev/null +++ b/tests/coaching/devanagari-transcribe-recovery.test.js @@ -0,0 +1,118 @@ +/** + * bd-bfy69 — the three layers, asserted on the SOURCE of audio.service.js. + * + * Source assertions rather than a live call, because requiring audio.service in + * a unit test boots the whole env-validation chain (process.exit 78). Two rules + * from language-protocol §7 apply and are honoured here: + * - comments are stripped before matching, so an assertion cannot pass on a + * comment that merely mentions the fix; + * - each guard is mutation-tested in `mutation` below — break the thing it + * protects, watch the matcher go red — so none of these is vacuous. + */ + +const fs = require('fs'); +const path = require('path'); + +const SRC_PATH = path.join(__dirname, '..', '..', 'bot', 'shared', 'services', 'audio.service.js'); +const RAW = fs.readFileSync(SRC_PATH, 'utf8'); +const SRC = RAW + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + +describe('bd-bfy69 layer 1 — do not invite Hindi', () => { + it('takes the default hints from LANGUAGE_OFFER, not a second hardcoded list', () => { + expect(SRC).toMatch(/const\s*\{\s*LANGUAGE_OFFER\s*\}\s*=\s*require\(['"]\.\.\/config\/languages['"]\)/); + expect(SRC).toMatch(/languageHints\s*=\s*normalizedLanguage\s*\?\s*\[normalizedLanguage\]\s*:\s*\[\.\.\.LANGUAGE_OFFER\]/); + }); + + it('no longer hints four languages this deployment does not serve', () => { + // es / ar / pa / ta widened the identifier's search space; Hindi is what it + // settled on for Urdu speech. + expect(SRC).not.toMatch(/\['en',\s*'ur',\s*'es',\s*'ar',\s*'pa',\s*'ta'\]/); + }); + + it('LANGUAGE_OFFER really is the two languages we mean', () => { + const { LANGUAGE_OFFER } = require('../../bot/shared/config/languages'); + expect([...LANGUAGE_OFFER].sort()).toEqual(['en', 'ur']); + }); + + it('still honours an explicit caller language — reading assessment is untouched', () => { + expect(SRC).toMatch(/normalizedLanguage\s*\?\s*\[normalizedLanguage\]/); + }); +}); + +describe('bd-bfy69 layer 2 — retry with a forced Urdu hint', () => { + it('wraps the single attempt rather than replacing it', () => { + expect(SRC).toMatch(/static async transcribe\(/); + expect(SRC).toMatch(/static async _transcribeOnce\(/); + expect(SRC).toMatch(/await this\._transcribeOnce\(audioPath, enableDiarization, language\)/); + }); + + it('retries with the literal Urdu hint', () => { + expect(SRC).toMatch(/await this\._transcribeOnce\(audioPath, enableDiarization, ['"]ur['"]\)/); + }); + + it('only retries when the caller left the language open', () => { + // A caller that pinned a language already got a single hint; re-running the + // identical request would return the identical answer. + expect(SRC).toMatch(/if \(!language\) \{[\s\S]*_transcribeOnce\(audioPath, enableDiarization, ['"]ur['"]\)/); + }); + + it('returns early and unchanged when the transcript is already clean', () => { + expect(SRC).toMatch(/if \(!result \|\| !hasDevanagari\(result\.text\)\) return result;/); + }); +}); + +describe('bd-bfy69 layer 3 — the guarantee', () => { + it('runs the guard over the text before returning', () => { + expect(SRC).toMatch(/ensureNoDevanagari\(result\.text/); + }); + + it('overwrites the language label too, so a report cannot inherit the wrong script', () => { + // Soniox reported 'en'/'hindi' for speech we have just written in Urdu. + // resolveReportLanguage reads this field to pick the report's branch. + expect(SRC).toMatch(/language:\s*['"]ur['"],\s*devanagariTransliterated:\s*true/); + }); + + it('logs every layer at error level, never info', () => { + // Extract each logToFile(...) by matching parentheses — a regex cannot, + // because these calls contain nested objects and an arrow function, and a + // lazy `\);` stops at the first inner one and silently under-tests. + const calls = []; + for (let i = SRC.indexOf('logToFile('); i !== -1; i = SRC.indexOf('logToFile(', i + 1)) { + let depth = 0; + let j = i + 'logToFile'.length; + for (; j < SRC.length; j++) { + if (SRC[j] === '(') depth++; + else if (SRC[j] === ')') { depth--; if (depth === 0) break; } + } + calls.push(SRC.slice(i, j + 1)); + } + const relevant = calls.filter((c) => c.includes('❌') && /Devanagari/i.test(c)); + expect(relevant.length).toBeGreaterThanOrEqual(3); + for (const call of relevant) { + expect(call).toMatch(/,\s*['"]error['"]\s*,?\s*\)$/); + } + }); +}); + +describe('mutation — each guard above can actually fail', () => { + // language-protocol §7.3: a guard never proven capable of failing is not a + // guard. Break the source in memory and confirm the matcher goes red. + const broken = (find, replace) => SRC.replace(find, replace); + + it('the hint assertion fails if the offer wiring is removed', () => { + const b = broken(/\[\.\.\.LANGUAGE_OFFER\]/, "['en','ur','es','ar','pa','ta']"); + expect(b).not.toMatch(/languageHints\s*=\s*normalizedLanguage\s*\?\s*\[normalizedLanguage\]\s*:\s*\[\.\.\.LANGUAGE_OFFER\]/); + }); + + it('the retry assertion fails if the forced-Urdu retry is removed', () => { + const b = broken(/_transcribeOnce\(audioPath, enableDiarization, 'ur'\)/, '_transcribeOnce(audioPath, enableDiarization, null)'); + expect(b).not.toMatch(/await this\._transcribeOnce\(audioPath, enableDiarization, ['"]ur['"]\)/); + }); + + it('the guard assertion fails if ensureNoDevanagari is removed', () => { + const b = broken(/ensureNoDevanagari\(result\.text/, 'passthrough(result.text'); + expect(b).not.toMatch(/ensureNoDevanagari\(result\.text/); + }); +});