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:
31
backend/src/common/digits.spec.ts
Normal file
31
backend/src/common/digits.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
24
backend/src/common/digits.ts
Normal file
24
backend/src/common/digits.ts
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user