fix(backend): read a tooth code whatever script its digits are in

The extraction model transcribes Persian speech, so it can hand back "۲۶"
in Persian digits or "2 6" from a digit-by-digit dictation. Both were
compared literally against /^[1-8][1-8]$/, missed, and fell through to the
positional branch with no quadrant — where the tooth was reported as "not
understood". The clinician loses a tooth and is told the words were the
problem.

normalizeFdiCode() now runs at both the branch choice and the final
validation, so the two cannot disagree. toLatinDigits moves out of
jalali.ts into common/digits.ts: it was exported but unused in production,
and a tooth module reaching into the calendar module would read as an
accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 04:42:09 +08:00
parent 647ff65b00
commit 17d3c5ca25
9 changed files with 115 additions and 54 deletions

View File

@@ -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',
);
});
});

View File

@@ -0,0 +1,24 @@
/**
* Persian (Extended Arabic-Indic, U+06F0U+06F9) zero, and Arabic-Indic (U+0660U+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);
});
}

View File

@@ -6,6 +6,8 @@
* midline pairs (1121, 4131) are neighbours, exactly as the chart treats them. * midline pairs (1121, 4131) are neighbours, exactly as the chart treats them.
*/ */
import { toLatinDigits } from './digits';
export type Arch = 'upper' | 'lower'; export type Arch = 'upper' | 'lower';
/** Which side of the *patient*, not of the screen. Quadrant 1 is the patient's upper right. */ /** 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); 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 { function archOrder(tooth: string): readonly string[] | null {
if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth)) if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth))
return FDI_UPPER_ARCH_ORDER; return FDI_UPPER_ARCH_ORDER;

View File

@@ -5,7 +5,6 @@ import {
jalaliDaysInMonth, jalaliDaysInMonth,
jalaliToGregorian, jalaliToGregorian,
jalaliToIsoDate, jalaliToIsoDate,
toLatinDigits,
} from './jalali'; } from './jalali';
describe('jalali calendar', () => { describe('jalali calendar', () => {
@@ -87,29 +86,4 @@ describe('jalali calendar', () => {
expect(jalaliDaysInMonth(1404, 13)).toBe(0); 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',
);
});
});
}); });

View File

@@ -158,28 +158,6 @@ export function jalaliDaysInMonth(jy: number, jm: number): number {
return isJalaliLeapYear(jy) ? 30 : 29; return isJalaliLeapYear(jy) ? 30 : 29;
} }
/**
* Persian (Extended Arabic-Indic, U+06F0U+06F9) zero, and Arabic-Indic (U+0660U+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. */ /** True when the triple is a real Jalali date inside the supported year range. */
export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean { export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
if (!Number.isInteger(jy) || !Number.isInteger(jm) || !Number.isInteger(jd)) { if (!Number.isInteger(jy) || !Number.isInteger(jm) || !Number.isInteger(jd)) {

View File

@@ -56,6 +56,33 @@ describe('VOICE_INTENT_JSON_SCHEMA', () => {
}); });
describe('toVoiceIntent', () => { 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', () => { it('narrows a positional tooth', () => {
const result = toVoiceIntent(wire({ teeth: [positionalTooth] })); const result = toVoiceIntent(wire({ teeth: [positionalTooth] }));
expect(result.teeth[0]).toEqual({ expect(result.teeth[0]).toEqual({

View File

@@ -1,3 +1,4 @@
import { normalizeFdiCode } from '../../common/fdi';
import type { import type {
ConnectedSpanIntent, ConnectedSpanIntent,
DueIntent, DueIntent,
@@ -180,7 +181,10 @@ const FDI_SHAPE = /^[1-8][1-8]$/;
function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent { function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
const spoken = typeof wire?.spoken === 'string' ? wire.spoken : ''; 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 // 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. // fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely.
if (FDI_SHAPE.test(fdi)) { if (FDI_SHAPE.test(fdi)) {

View File

@@ -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', () => { it('trims an explicit code, matching normalizeTeeth', () => {
expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']); expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']);
}); });

View File

@@ -1,4 +1,4 @@
import { isFdiTooth, toFdi } from '../../common/fdi'; import { isFdiTooth, normalizeFdiCode, toFdi } from '../../common/fdi';
import type { ToothIntent, UnresolvedItem } from './voice.types'; import type { ToothIntent, UnresolvedItem } from './voice.types';
export type ToothResolution = { export type ToothResolution = {
@@ -9,10 +9,10 @@ export type ToothResolution = {
/** Everything here parses untrusted model output, so nothing may throw. */ /** Everything here parses untrusted model output, so nothing may throw. */
function normalizedFdi(intent: ToothIntent): string { function normalizedFdi(intent: ToothIntent): string {
const raw = (intent as { fdi?: unknown }).fdi; // Same normalisation the wire layer used to pick this branch, so the two cannot
// Trimmed for parity with normalizeTeeth — '14 ' is tooth 14 through the treatment API // disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and
// and must not be "malformed" here. // neither may be reported as malformed here.
return typeof raw === 'string' ? raw.trim() : ''; return normalizeFdiCode((intent as { fdi?: unknown }).fdi);
} }
/** /**