Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions bot/shared/services/coaching/report-v2/hero-report.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 });
Expand Down
21 changes: 18 additions & 3 deletions bot/shared/services/coaching/report-v2/hero-report.template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
43 changes: 43 additions & 0 deletions bot/shared/services/coaching/report-v2/report-language.js
Original file line number Diff line number Diff line change
@@ -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 };
61 changes: 61 additions & 0 deletions tests/coaching/hero-report-language-resolution.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
131 changes: 131 additions & 0 deletions tests/coaching/hero-report-urdu-font-fallback.test.js
Original file line number Diff line number Diff line change
@@ -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 <style>. We assert on the CSS
* the browser actually receives, not on the template source — and the source is
* comment-stripped wherever we do read it (language-protocol §7.1: a source
* assertion that lands on a comment passes against broken code).
*/
function fontFamilyOf(html, selector) {
const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const rule = new RegExp(`${esc}\\{([^}]*)\\}`).exec(html);
if (!rule) throw new Error(`no CSS rule found for "${selector}"`);
const decl = /font-family:([^;}]+)/.exec(rule[1]);
return decl ? decl[1].trim() : null;
}

// Every language code that has actually appeared in transcript_language on prod
// and is NOT ur/ar — each one drives the report into the Latin/LTR branch.
const MISLABELS = ['en', 'hindi', 'javanese', 'sindhi'];

describe('bd-osmk0 — Urdu must never be fontless, whatever the report language is', () => {
describe.each(MISLABELS)('report language "%s" with an Urdu body', (lang) => {
const html = buildHeroReportHtml(urduBodyMislabelled(lang));

it('still carries the Urdu content (guards against the test going vacuous)', () => {
expect(html).toContain(URDU_AFFIRMATION);
expect(html).toContain(URDU_MOMENT);
});

it('names an Urdu-capable face in the BODY font stack', () => {
expect(fontFamilyOf(html, '.report')).toMatch(/NastaliqUrdu/);
});

it('names an Urdu-capable face in the HEADING font stack', () => {
expect(fontFamilyOf(html, '.hero h1')).toMatch(/NastaliqUrdu/);
});

it('embeds the NastaliqUrdu @font-face with a real, non-empty payload', () => {
expect(html).toMatch(
/@font-face\{font-family:'NastaliqUrdu';font-weight:400;src:url\(data:font\/ttf;base64,[A-Za-z0-9+/]{100,}/,
);
});
});

it('keeps Nastaliq FIRST for a correctly-labelled Urdu report (no regression)', () => {
const html = buildHeroReportHtml(urduBodyMislabelled('ur'));
expect(fontFamilyOf(html, '.report')).toMatch(/^'NastaliqUrdu'/);
expect(html).toContain('dir="rtl"');
});

it('keeps Arabic on Naskh and still reaches it from the Latin branch', () => {
const ar = buildHeroReportHtml(urduBodyMislabelled('ar'));
expect(fontFamilyOf(ar, '.report')).toMatch(/^'NaskhArabic'/);
const en = buildHeroReportHtml(urduBodyMislabelled('en'));
expect(fontFamilyOf(en, '.report')).toMatch(/NaskhArabic/);
});

it('leaves Latin resolving to Lexend/Fraunces FIRST in the Latin branch', () => {
const html = buildHeroReportHtml(urduBodyMislabelled('en'));
expect(fontFamilyOf(html, '.report')).toMatch(/^'Lexend'/);
expect(fontFamilyOf(html, '.hero h1')).toMatch(/^'Fraunces'/);
});

it('has no branch that names Lexend or Fraunces as the ONLY family for prose', () => {
// language-protocol §7.1 — strip comments so the assertion cannot pass on
// a comment that merely mentions the fix.
const src = fs.readFileSync(TEMPLATE_PATH, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
const bodyFam = /const bodyFam = ([^\n]+)/.exec(src)[1];
const headFam = /const headFam = ([^\n]+)/.exec(src)[1];
// The Latin arm of each ternary must not end at a Latin-only family.
expect(bodyFam).toMatch(/NastaliqUrdu[^\n]*NaskhArabic|NaskhArabic[^\n]*NastaliqUrdu/);
expect(headFam).toMatch(/NastaliqUrdu[^\n]*NaskhArabic|NaskhArabic[^\n]*NastaliqUrdu/);
});
});
Loading