feat(treatment): add voice detail entry #65
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.
|
* midline pairs (11–21, 41–31) 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;
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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+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. */
|
/** 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)) {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user