feat(treatment): add voice detail entry #65

Merged
admin merged 32 commits from feat/voice-treatment-entry into master 2026-08-24 11:13:48 +03:30
9 changed files with 115 additions and 54 deletions
Showing only changes of commit c09698aea2 - Show all commits

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.
*/
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;

View File

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

View File

@@ -158,28 +158,6 @@ export function jalaliDaysInMonth(jy: number, jm: number): number {
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. */
export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
if (!Number.isInteger(jy) || !Number.isInteger(jm) || !Number.isInteger(jd)) {

View File

@@ -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({

View File

@@ -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)) {

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', () => {
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';
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);
}
/**