diff --git a/backend/src/common/digits.spec.ts b/backend/src/common/digits.spec.ts new file mode 100644 index 0000000..01a834a --- /dev/null +++ b/backend/src/common/digits.spec.ts @@ -0,0 +1,31 @@ +import { toLatinDigits } from './digits'; + +/** + * Exercised by the voice pipeline on two untrusted inputs: spoken dates, and the tooth + * code the extraction model echoes back — a Persian-digit "۲۶" that fails to normalise + * costs the clinician a tooth, silently. + */ +describe('toLatinDigits', () => { + it('normalises Persian digits and leaves everything else alone', () => { + expect( + toLatinDigits('\u06F1\u06F4\u06F0\u06F4/\u06F0\u06F7/\u06F2\u06F5'), + ).toBe('1404/07/25'); + expect(toLatinDigits('1404/07/25')).toBe('1404/07/25'); + expect(toLatinDigits('\u062F\u0646\u062F\u0627\u0646 \u06F1\u06F4')).toBe( + '\u062F\u0646\u062F\u0627\u0646 14', + ); + }); + + it('also normalises the Arabic-Indic block, which ASR output can carry', () => { + // U+0660..U+0669, distinct code points from the Persian U+06F0..U+06F9 block. + expect( + toLatinDigits('\u0661\u0664\u0660\u0664/\u0660\u0667/\u0662\u0665'), + ).toBe('1404/07/25'); + }); + + it('normalises a transcript that mixes both blocks with ASCII', () => { + expect(toLatinDigits('\u06F1\u06F4 and \u0661\u0665 and 16')).toBe( + '14 and 15 and 16', + ); + }); +}); diff --git a/backend/src/common/digits.ts b/backend/src/common/digits.ts new file mode 100644 index 0000000..cb8663a --- /dev/null +++ b/backend/src/common/digits.ts @@ -0,0 +1,24 @@ +/** + * Persian (Extended Arabic-Indic, U+06F0–U+06F9) zero, and Arabic-Indic (U+0660–U+0669) + * zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript. + */ +const PERSIAN_ZERO = 0x06f0; +const ARABIC_INDIC_ZERO = 0x0660; + +/** + * Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through. + * + * Deliberately wider than the frontend original, which only handles the Persian block: + * this parses model/ASR output rather than keystrokes, so both blocks must be accepted + * or a spoken date or tooth number silently degrades to "unresolved". + * + * Lives on its own rather than inside jalali.ts because tooth codes need it too, and a + * tooth module reaching into the calendar module would read as an accident. + */ +export function toLatinDigits(value: string): string { + return value.replace(/[۰-۹٠-٩]/g, (ch) => { + const code = ch.charCodeAt(0); + const base = code >= PERSIAN_ZERO ? PERSIAN_ZERO : ARABIC_INDIC_ZERO; + return String(code - base); + }); +} diff --git a/backend/src/common/fdi.ts b/backend/src/common/fdi.ts index e60ba3c..192967d 100644 --- a/backend/src/common/fdi.ts +++ b/backend/src/common/fdi.ts @@ -6,6 +6,8 @@ * midline pairs (11–21, 41–31) are neighbours, exactly as the chart treats them. */ +import { toLatinDigits } from './digits'; + export type Arch = 'upper' | 'lower'; /** Which side of the *patient*, not of the screen. Quadrant 1 is the patient's upper right. */ @@ -65,6 +67,19 @@ export function isFdiTooth(value: unknown): value is string { return typeof value === 'string' && FDI_TOOTH_IDS.has(value); } +/** + * Clean up a tooth code the extraction model echoed back, before it is matched. + * + * The model is transcribing Persian speech, so it can hand back "۲۶" in Persian digits or + * "2 6" from a digit-by-digit dictation. Neither matches an FDI code literally, and a + * near-miss here does not fail loudly — the tooth quietly turns into "not understood". + * Returns '' for anything that is not a string. + */ +export function normalizeFdiCode(value: unknown): string { + if (typeof value !== 'string') return ''; + return toLatinDigits(value).replace(/\s+/g, ''); +} + function archOrder(tooth: string): readonly string[] | null { if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth)) return FDI_UPPER_ARCH_ORDER; diff --git a/backend/src/common/jalali.spec.ts b/backend/src/common/jalali.spec.ts index aeb7ec9..df1bb3c 100644 --- a/backend/src/common/jalali.spec.ts +++ b/backend/src/common/jalali.spec.ts @@ -5,7 +5,6 @@ import { jalaliDaysInMonth, jalaliToGregorian, jalaliToIsoDate, - toLatinDigits, } from './jalali'; describe('jalali calendar', () => { @@ -87,29 +86,4 @@ describe('jalali calendar', () => { expect(jalaliDaysInMonth(1404, 13)).toBe(0); }); }); - - describe('toLatinDigits', () => { - it('normalises Persian digits and leaves everything else alone', () => { - expect( - toLatinDigits('\u06F1\u06F4\u06F0\u06F4/\u06F0\u06F7/\u06F2\u06F5'), - ).toBe('1404/07/25'); - expect(toLatinDigits('1404/07/25')).toBe('1404/07/25'); - expect(toLatinDigits('\u062F\u0646\u062F\u0627\u0646 \u06F1\u06F4')).toBe( - '\u062F\u0646\u062F\u0627\u0646 14', - ); - }); - - it('also normalises the Arabic-Indic block, which ASR output can carry', () => { - // U+0660..U+0669, distinct code points from the Persian U+06F0..U+06F9 block. - expect( - toLatinDigits('\u0661\u0664\u0660\u0664/\u0660\u0667/\u0662\u0665'), - ).toBe('1404/07/25'); - }); - - it('normalises a transcript that mixes both blocks with ASCII', () => { - expect(toLatinDigits('\u06F1\u06F4 and \u0661\u0665 and 16')).toBe( - '14 and 15 and 16', - ); - }); - }); }); diff --git a/backend/src/common/jalali.ts b/backend/src/common/jalali.ts index 4f03a28..989cb6e 100644 --- a/backend/src/common/jalali.ts +++ b/backend/src/common/jalali.ts @@ -158,28 +158,6 @@ export function jalaliDaysInMonth(jy: number, jm: number): number { return isJalaliLeapYear(jy) ? 30 : 29; } -/** - * Persian (Extended Arabic-Indic, U+06F0–U+06F9) zero, and Arabic-Indic (U+0660–U+0669) - * zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript. - */ -const PERSIAN_ZERO = 0x06f0; -const ARABIC_INDIC_ZERO = 0x0660; - -/** - * Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through. - * - * Deliberately wider than the frontend original, which only handles the Persian block: - * this parses model/ASR output rather than keystrokes, so both blocks must be accepted - * or a spoken date silently degrades to "unresolved". - */ -export function toLatinDigits(value: string): string { - return value.replace(/[\u06F0-\u06F9\u0660-\u0669]/g, (ch) => { - const code = ch.charCodeAt(0); - const base = code >= PERSIAN_ZERO ? PERSIAN_ZERO : ARABIC_INDIC_ZERO; - return String(code - base); - }); -} - /** True when the triple is a real Jalali date inside the supported year range. */ export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean { if (!Number.isInteger(jy) || !Number.isInteger(jm) || !Number.isInteger(jd)) { diff --git a/backend/src/modules/voice/extraction.wire.spec.ts b/backend/src/modules/voice/extraction.wire.spec.ts index 6d9efb9..f40bcc7 100644 --- a/backend/src/modules/voice/extraction.wire.spec.ts +++ b/backend/src/modules/voice/extraction.wire.spec.ts @@ -56,6 +56,33 @@ describe('VOICE_INTENT_JSON_SCHEMA', () => { }); describe('toVoiceIntent', () => { + it('takes the explicit branch for a code the model wrote in Persian digits', () => { + // The model is reading Persian text back, so "۲۶" and a digit-by-digit "2 6" both + // reach us. Matching only ASCII drops the tooth into the positional branch with no + // quadrant, where it is reported as unresolved — the clinician loses a tooth and is + // told the words were the problem. + for (const raw of ['\u06F2\u06F6', '2 6', ' 26 ', '\u0662\u0666']) { + const [tooth] = toVoiceIntent( + wire({ + teeth: [ + { + spoken: '\u0628\u06CC\u0633\u062A \u0648 \u0634\u0634', + fdi: raw, + arch: null, + side: null, + position: null, + }, + ], + }), + ).teeth; + expect(tooth).toEqual({ + kind: 'explicit', + fdi: '26', + spoken: '\u0628\u06CC\u0633\u062A \u0648 \u0634\u0634', + }); + } + }); + it('narrows a positional tooth', () => { const result = toVoiceIntent(wire({ teeth: [positionalTooth] })); expect(result.teeth[0]).toEqual({ diff --git a/backend/src/modules/voice/extraction.wire.ts b/backend/src/modules/voice/extraction.wire.ts index af9ac1c..b91aed1 100644 --- a/backend/src/modules/voice/extraction.wire.ts +++ b/backend/src/modules/voice/extraction.wire.ts @@ -1,3 +1,4 @@ +import { normalizeFdiCode } from '../../common/fdi'; import type { ConnectedSpanIntent, DueIntent, @@ -180,7 +181,10 @@ const FDI_SHAPE = /^[1-8][1-8]$/; function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent { const spoken = typeof wire?.spoken === 'string' ? wire.spoken : ''; - const fdi = typeof wire?.fdi === 'string' ? wire.fdi.trim() : ''; + // Persian digits and digit-by-digit dictation ("۲۶", "2 6") are FDI codes that do not + // match literally; without normalising first they fall through to the positional branch + // with no quadrant and are reported as unresolved. + const fdi = normalizeFdiCode(wire?.fdi); // Only take the explicit branch for something actually FDI-shaped. A model that emits // fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely. if (FDI_SHAPE.test(fdi)) { diff --git a/backend/src/modules/voice/tooth-intent.resolver.spec.ts b/backend/src/modules/voice/tooth-intent.resolver.spec.ts index efaa894..6b95091 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.spec.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.spec.ts @@ -134,6 +134,14 @@ describe('resolveToothIntents', () => { } }); + it('reads a spoken number as its FDI code, digits in any script', () => { + // The product rule: the number the clinician says IS the tooth. 26 = quadrant 2 + // (patient's upper left) + position 6 = first molar. + for (const raw of ['26', ' 26 ', '2 6', '\u06F2\u06F6', '\u0662\u0666']) { + expect(resolveToothIntents([explicit(raw, 'x')]).teeth).toEqual(['26']); + } + }); + it('trims an explicit code, matching normalizeTeeth', () => { expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']); }); diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts index 4c3d48d..7b5e151 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -1,4 +1,4 @@ -import { isFdiTooth, toFdi } from '../../common/fdi'; +import { isFdiTooth, normalizeFdiCode, toFdi } from '../../common/fdi'; import type { ToothIntent, UnresolvedItem } from './voice.types'; export type ToothResolution = { @@ -9,10 +9,10 @@ export type ToothResolution = { /** Everything here parses untrusted model output, so nothing may throw. */ function normalizedFdi(intent: ToothIntent): string { - const raw = (intent as { fdi?: unknown }).fdi; - // Trimmed for parity with normalizeTeeth — '14 ' is tooth 14 through the treatment API - // and must not be "malformed" here. - return typeof raw === 'string' ? raw.trim() : ''; + // Same normalisation the wire layer used to pick this branch, so the two cannot + // disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and + // neither may be reported as malformed here. + return normalizeFdiCode((intent as { fdi?: unknown }).fdi); } /**