diff --git a/bot/shared/services/audio.service.js b/bot/shared/services/audio.service.js index 8215d41..8de66a8 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 0000000..edcf8db --- /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 0000000..1753a04 --- /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 0000000..7d2a02f --- /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/); + }); +});