diff --git a/bot/shared/services/coaching/report-v2/hero-report.service.js b/bot/shared/services/coaching/report-v2/hero-report.service.js index 25215d5..de3d3f3 100644 --- a/bot/shared/services/coaching/report-v2/hero-report.service.js +++ b/bot/shared/services/coaching/report-v2/hero-report.service.js @@ -13,6 +13,7 @@ const { buildScoreViewModel } = require('./score-adapter.service'); const { generateReportNarrative } = require('./narrative.service'); const { buildHeroReportHtml, buildReportCaption } = require('./hero-report.template'); const { buildClassroomPhotoVm } = require('./classroom-photo-vm'); +const { resolveReportLanguage } = require('./report-language'); const { loadTrendData } = require('../coaching-trend.service'); const { downloadFromR2, extractKeyFromUrl } = require('../../../storage/r2'); const { htmlToImage } = require('../../../utils/html-to-pdf'); @@ -27,8 +28,13 @@ const { logToFile } = require('../../../utils/logger'); * @returns {Promise<{png:Buffer, caption:string}>} */ async function generateHeroReport(session, analysis, opts = {}) { - const { teacherName = 'Teacher', commitmentAction = '', language, brand } = opts; - const lang = language || analysis.language || session.transcript_language || 'en'; + const { teacherName = 'Teacher', commitmentAction = '', brand } = opts; + // bd-gipr1 — this used to be `language || analysis.language || + // session.transcript_language || 'en'`, which let an STT label choose both the + // template's script branch and the language the narrative LLM writes in. + // transcript_language has been 'hindi'/'javanese'/'sindhi' on prod (bd-bfy69). + // resolveReportLanguage() only ever returns a language we actually offer. + const lang = resolveReportLanguage(opts, analysis, session); const framework = (analysis.framework || 'oecd').toLowerCase(); const score = buildScoreViewModel(analysis, { framework, language: lang }); diff --git a/bot/shared/services/coaching/report-v2/hero-report.template.js b/bot/shared/services/coaching/report-v2/hero-report.template.js index 8c5213f..e4b9f7d 100644 --- a/bot/shared/services/coaching/report-v2/hero-report.template.js +++ b/bot/shared/services/coaching/report-v2/hero-report.template.js @@ -131,8 +131,19 @@ function buildHeroReportHtml(vm) { // LLM-generated BODY (affirmation, moment, strength copy) stays in `lang`, and // layout stays RTL for ur/ar. The card heading is chrome, so it is English too. const C = CHROME.en; - const headFam = RTL ? (lang === 'ar' ? `'NaskhArabic',serif` : `'NastaliqUrdu',serif`) : `'Fraunces',serif`; - const bodyFam = RTL ? (lang === 'ar' ? `'NaskhArabic',serif` : `'NastaliqUrdu',serif`) : `'Lexend',sans-serif`; + // bd-osmk0 — the Urdu/Arabic faces are a PERMANENT FALLBACK in EVERY branch, + // never gated on `lang`. `lang` here is only as trustworthy as its source, and + // its source includes `session.transcript_language` — an STT label. Since + // 2026-08-11 Soniox has labelled Urdu classroom audio `en`/`hindi`/`javanese`, + // which drove Urdu-bodied reports down the Latin arm, where nothing in the + // stack covered Urdu. Railway's Chromium has no system fonts, so those glyphs + // painted as tofu boxes (44 reports); macOS substitutes a system Nastaliq, + // which is exactly why it always looked right locally. Latin still resolves to + // Lexend/Fraunces first — the fallbacks only catch what those cannot draw. + // Same fix bd-2644 applied to the coach card. + const NON_LATIN = `'NastaliqUrdu','NaskhArabic'`; + const headFam = RTL ? (lang === 'ar' ? `'NaskhArabic','NastaliqUrdu',serif` : `'NastaliqUrdu','NaskhArabic',serif`) : `'Fraunces',${NON_LATIN},serif`; + const bodyFam = RTL ? (lang === 'ar' ? `'NaskhArabic','NastaliqUrdu',serif` : `'NastaliqUrdu','NaskhArabic',serif`) : `'Lexend',${NON_LATIN},sans-serif`; const dir = RTL ? 'rtl' : 'ltr'; // Split on tags AND HTML entities so we don't tear an entity apart. Without @@ -203,7 +214,11 @@ function buildHeroReportHtml(vm) { .pgrid{display:flex;gap:14px} .pframe{flex:1;max-width:342px;border-radius:14px;overflow:hidden;border:1px solid ${P.barBg};background:#fff;box-shadow:0 3px 13px rgba(51,55,72,.10)} .pframe img{width:100%;height:158px;object-fit:cover;display:block;background:${P.barBg}} - .pframe .pcap{font-size:11px;color:${P.quiet};padding:8px 12px;font-family:'Lexend',sans-serif;letter-spacing:.01em} + /* bd-osmk0 — the photo caption is LLM-generated prose, so it can be Urdu even + on a correctly-labelled ur report; it was pinned to Latin-only Lexend in + every branch. This is the bd-2362 class: the small-print element nobody + re-checks after the main stacks are fixed. */ + .pframe .pcap{font-size:11px;color:${P.quiet};padding:8px 12px;font-family:'Lexend',${NON_LATIN},sans-serif;letter-spacing:.01em} .journey{padding:14px 42px 0}.j-cap{font-size:12.5px;color:${P.note};line-height:${RTL ? '1.7' : '1.5'};margin-top:2px} .try{margin:16px 42px 0;background:${P.tryGrad};color:#fff;border-radius:16px;padding:18px 24px} .try .label{color:${P.tryLabel};opacity:1;margin-bottom:6px} diff --git a/bot/shared/services/coaching/report-v2/report-language.js b/bot/shared/services/coaching/report-v2/report-language.js new file mode 100644 index 0000000..7f41366 --- /dev/null +++ b/bot/shared/services/coaching/report-v2/report-language.js @@ -0,0 +1,43 @@ +/** + * bd-gipr1 — decide which language a hero report renders and is written in. + * + * Split out of hero-report.service.js so the decision is unit-testable without + * booting the service's whole dependency graph (R2, the LLM client, env + * validation). The logic is the interesting part; everything around it is I/O. + * + * The rule it enforces is language-protocol invariant 7: a language this + * deployment does not offer must never take effect. That matters here because + * one of the candidate sources is `coaching_sessions.transcript_language` — an + * STT LABEL, not a decision. Soniox has returned 'hindi', 'javanese' and + * 'sindhi' for Urdu classroom audio since 2026-08-11 (bd-bfy69), and that value + * was selecting BOTH the template's script branch and the language the + * narrative LLM was instructed to write in. + * + * An unofferable label lands on `offerDefaultLanguage()` — Urdu — not on the + * emergency English floor. NIETE is a single Urdu-medium tenant, so "we could + * not read the label" is far better answered with Urdu than with English. + */ + +const { isOffered, offerDefaultLanguage } = require('../../../config/languages'); + +/** + * @param {object} [opts] - caller options; `opts.language` is an explicit override + * @param {object} [analysis] - enhancedAnalysis; `analysis.language` is the analyser's view + * @param {object} [session] - coaching_sessions row; `session.transcript_language` is the STT label + * @returns {string} a language code guaranteed to be in LANGUAGE_OFFER + */ +function resolveReportLanguage(opts, analysis, session) { + const candidates = [ + opts && opts.language, + analysis && analysis.language, + session && session.transcript_language, + ]; + // First OFFERED candidate wins. An unofferable one is skipped, not fatal — + // a junk analysis label must not shadow a perfectly good transcript label. + for (const candidate of candidates) { + if (isOffered(candidate)) return candidate; + } + return offerDefaultLanguage(); +} + +module.exports = { resolveReportLanguage }; diff --git a/tests/coaching/hero-report-language-resolution.test.js b/tests/coaching/hero-report-language-resolution.test.js new file mode 100644 index 0000000..185e1c4 --- /dev/null +++ b/tests/coaching/hero-report-language-resolution.test.js @@ -0,0 +1,61 @@ +/** + * bd-gipr1 — the report language must never be decided by an STT label that + * this deployment does not even offer. + * + * `generateHeroReport` resolved: + * lang = language || analysis.language || session.transcript_language || 'en' + * + * `session.transcript_language` is whatever Soniox returned. On prod it has been + * 'hindi', 'javanese' and 'sindhi' (bd-bfy69) — none of which NIETE offers. That + * value then chose the template's script branch AND the language the narrative + * LLM was told to write in, so one bad label mis-rendered the report and + * mis-wrote its prose. + * + * language-protocol invariant 7: never let a language outside the offer take + * effect. `isOffered()` is the gate; `offerDefaultLanguage()` ('ur' here, + * deliberately NOT the emergency English floor) is where an unofferable label + * lands, because NIETE is a single Urdu-medium tenant. + */ + +const { resolveReportLanguage } = require('../../bot/shared/services/coaching/report-v2/report-language'); + +const sess = (transcript_language) => ({ transcript_language }); + +describe('bd-gipr1 — report language is constrained to the offer', () => { + it('honours an explicit caller language above everything else', () => { + expect(resolveReportLanguage({ language: 'en' }, { language: 'ur' }, sess('ur'))).toBe('en'); + }); + + it('falls through caller → analysis → transcript, while each is offered', () => { + expect(resolveReportLanguage({}, { language: 'ur' }, sess('en'))).toBe('ur'); + expect(resolveReportLanguage({}, {}, sess('en'))).toBe('en'); + expect(resolveReportLanguage({}, {}, sess('ur'))).toBe('ur'); + }); + + it.each(['hindi', 'javanese', 'sindhi', 'HINDI', 'pa', 'sw', ''])( + 'refuses the unofferable STT label %p and lands on the offer default', + (label) => { + expect(resolveReportLanguage({}, {}, sess(label))).toBe('ur'); + }, + ); + + it('skips an unofferable label to reach a LATER offered candidate', () => { + // analysis.language is junk, but the transcript label is a real offer member. + expect(resolveReportLanguage({}, { language: 'javanese' }, sess('en'))).toBe('en'); + }); + + it('never returns a value outside the offer, for any input', () => { + const { LANGUAGE_OFFER } = require('../../bot/shared/config/languages'); + const junk = [undefined, null, 0, {}, [], 'hindi', 'xx-YY', 'en-US', 'urdu']; + for (const a of junk) { + for (const b of junk) { + expect(LANGUAGE_OFFER).toContain(resolveReportLanguage({ language: a }, { language: b }, sess(a))); + } + } + }); + + it('tolerates a missing session/analysis without throwing', () => { + expect(resolveReportLanguage({}, undefined, undefined)).toBe('ur'); + expect(resolveReportLanguage(undefined, undefined, undefined)).toBe('ur'); + }); +}); diff --git a/tests/coaching/hero-report-urdu-font-fallback.test.js b/tests/coaching/hero-report-urdu-font-fallback.test.js new file mode 100644 index 0000000..f6639e6 --- /dev/null +++ b/tests/coaching/hero-report-urdu-font-fallback.test.js @@ -0,0 +1,131 @@ +/** + * bd-osmk0 — an Urdu-content report rendered in the LATIN/LTR branch printed + * every Urdu glyph as a TOFU BOX on production. + * + * How a report ends up there: `hero-report.service.js` resolves + * lang = language || analysis.language || session.transcript_language || 'en' + * and `transcript_language` is an STT label. Since 2026-08-11 Soniox has been + * labelling Urdu classroom audio as `en` / `hindi` / `javanese`, so `RTL` is + * false while the LLM body is still Urdu. The template then selected + * bodyFam = 'Lexend',sans-serif headFam = 'Fraunces',serif + * neither of which covers Urdu, and neither of which names the NastaliqUrdu + * @font-face the template already embeds. Railway's Chromium has NO system + * fonts, so the glyphs had nothing to fall back to and painted as boxes. + * (macOS substitutes a system Nastaliq, which is why it always looked fine + * locally — verified with CSS.getPlatformFontsForNode: in the LTR branch the + * Urdu run resolved to the SYSTEM 'Noto Nastaliq Urdu', isCustomFont=false.) + * + * 44 already-generated reports carry non-Latin script in a non-ur/ar branch. + * + * The fix is the one bd-2644 applied to the coach card: the Urdu/Arabic faces + * are a PERMANENT FALLBACK in every branch, never gated on the report language. + */ + +const fs = require('fs'); +const path = require('path'); +const { buildHeroReportHtml } = require('../../bot/shared/services/coaching/report-v2/hero-report.template'); + +const TEMPLATE_PATH = path.join( + __dirname, '..', '..', 'bot', 'shared', 'services', 'coaching', 'report-v2', 'hero-report.template.js', +); + +const URDU_AFFIRMATION = 'آپ نے آج بہت اچھا پڑھایا'; +const URDU_MOMENT = 'شاباش، بہت خوب'; + +/** An Urdu-BODY view-model whose `language` is the mislabelled STT code. */ +function urduBodyMislabelled(language) { + return { + language, + teacherName: 'mr. muhammad waqas', + topic: 'ریاضی', + date: '2026-08-19', + score: { overall: 74, marks: 110, max: 148 }, + groups: [{ name: 'Lesson Plan Fidelity', score: 32, max: 40, pct: 80 }], + tryNext: 'اگلی کلاس میں طلبہ سے کھلے سوالات پوچھیں', + trend: [], + narrative: { + affirmation: URDU_AFFIRMATION, + identity: 'آپ ایک محتاط استاد ہیں', + moments: [{ quote: URDU_MOMENT, why: 'اچھا لمحہ' }], + strength_name: 'واضح ہدایات', + strength_note: 'آپ کی ہدایات واضح تھیں', + horizon_title: 'مزید سوالات', + horizon_note: 'مزید کھلے سوالات کریں', + }, + }; +} + +/** + * Pull a declaration's value out of the rendered