diff --git a/backend/src/modules/voice/tooth-intent.resolver.spec.ts b/backend/src/modules/voice/tooth-intent.resolver.spec.ts new file mode 100644 index 0000000..b9cd7cf --- /dev/null +++ b/backend/src/modules/voice/tooth-intent.resolver.spec.ts @@ -0,0 +1,169 @@ +import { + resolveToothIntent, + resolveToothIntents, +} from './tooth-intent.resolver'; +import type { ToothIntent } from './voice.types'; + +const positional = ( + arch: 'upper' | 'lower', + side: 'patient_right' | 'patient_left', + position: number, + spoken = 'x', +): ToothIntent => ({ kind: 'positional', arch, side, position, spoken }); + +const explicit = (fdi: string, spoken = 'x'): ToothIntent => ({ + kind: 'explicit', + fdi, + spoken, +}); + +describe('resolveToothIntent', () => { + describe('positional intents', () => { + // "شش بالا راست" — upper right six — must be 16, not 26. A mirrored quadrant is a + // valid code for the wrong tooth and reaches the lab unnoticed. + it('resolves each quadrant from the patient perspective', () => { + expect(resolveToothIntent(positional('upper', 'patient_right', 6))).toBe( + '16', + ); + expect(resolveToothIntent(positional('upper', 'patient_left', 6))).toBe( + '26', + ); + expect(resolveToothIntent(positional('lower', 'patient_left', 6))).toBe( + '36', + ); + expect(resolveToothIntent(positional('lower', 'patient_right', 6))).toBe( + '46', + ); + }); + + it('returns null for an out-of-range position instead of clamping', () => { + expect( + resolveToothIntent(positional('upper', 'patient_right', 9)), + ).toBeNull(); + expect( + resolveToothIntent(positional('upper', 'patient_right', 0)), + ).toBeNull(); + }); + + it('returns null for a malformed arch or side', () => { + expect( + resolveToothIntent( + positional('sideways' as 'upper', 'patient_right', 6), + ), + ).toBeNull(); + expect( + resolveToothIntent( + positional('upper', 'viewer_right' as 'patient_right', 6), + ), + ).toBeNull(); + }); + }); + + describe('explicit intents', () => { + it('accepts a permanent FDI code', () => { + expect(resolveToothIntent(explicit('14'))).toBe('14'); + expect(resolveToothIntent(explicit('48'))).toBe('48'); + }); + + it('rejects deciduous codes rather than snapping to a permanent tooth', () => { + expect(resolveToothIntent(explicit('51'))).toBeNull(); + expect(resolveToothIntent(explicit('85'))).toBeNull(); + }); + + it('rejects nonsense', () => { + for (const value of ['', '1', '99', '140']) { + expect(resolveToothIntent(explicit(value))).toBeNull(); + } + }); + }); + + it('returns null for a missing or unknown intent shape', () => { + expect(resolveToothIntent(undefined as unknown as ToothIntent)).toBeNull(); + expect( + resolveToothIntent({ kind: 'guess' } as unknown as ToothIntent), + ).toBeNull(); + }); +}); + +describe('resolveToothIntents', () => { + it('resolves a mixed list and sorts the result', () => { + const result = resolveToothIntents([ + positional('upper', 'patient_right', 5, 'پنج بالا راست'), + explicit('14', 'یک چهار'), + ]); + expect(result.teeth).toEqual(['14', '15']); + expect(result.unresolved).toEqual([]); + }); + + it('collapses a tooth named twice', () => { + const result = resolveToothIntents([ + explicit('14', 'چهارده'), + positional('upper', 'patient_right', 4, 'چهار بالا راست'), + ]); + expect(result.teeth).toEqual(['14']); + }); + + it('reports what it could not understand instead of dropping it', () => { + const result = resolveToothIntents([ + explicit('14', 'یک چهار'), + explicit('51', 'دندان شیری'), + positional('upper', 'patient_right', 9, 'نه بالا راست'), + ]); + expect(result.teeth).toEqual(['14']); + expect(result.unresolved).toEqual([ + { spoken: 'دندان شیری', reason: 'not_permanent_tooth' }, + { spoken: 'نه بالا راست', reason: 'position_out_of_range' }, + ]); + }); + + it('does not repeat an identical unresolved item', () => { + const result = resolveToothIntents([ + explicit('51', 'شیری'), + explicit('51', 'شیری'), + ]); + expect(result.unresolved).toHaveLength(1); + }); + + it('survives a non-array where the model should have sent a list', () => { + // The model can return an object or a number here; that must degrade, not 500. + for (const bad of [undefined, null, 5, 'teeth', { fdi: '14' }]) { + expect(resolveToothIntents(bad as unknown as ToothIntent[])).toEqual({ + teeth: [], + unresolved: [], + }); + } + }); + + it('trims an explicit code, matching normalizeTeeth', () => { + expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']); + }); + + it('distinguishes a deciduous tooth from nonsense in the reason it reports', () => { + // '51' really is a (primary) tooth the chart cannot show; '99' is not a tooth at all. + expect( + resolveToothIntents([explicit('51', 'shiri')]).unresolved[0].reason, + ).toBe('not_permanent_tooth'); + for (const junk of ['99', '19', '140', '', 'ab']) { + expect( + resolveToothIntents([explicit(junk, `j-${junk}`)]).unresolved[0].reason, + ).toBe('malformed'); + } + }); + + it('keeps unresolved items separate when the model omits the spoken span', () => { + // Without `spoken` these are indistinguishable; collapsing them would hide a lost tooth. + const result = resolveToothIntents([ + { kind: 'explicit', fdi: '51' } as ToothIntent, + { kind: 'explicit', fdi: '52' } as ToothIntent, + ]); + expect(result.unresolved).toHaveLength(2); + }); + + it('handles an empty or missing list', () => { + expect(resolveToothIntents([])).toEqual({ teeth: [], unresolved: [] }); + expect(resolveToothIntents(undefined as unknown as ToothIntent[])).toEqual({ + teeth: [], + unresolved: [], + }); + }); +}); diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts new file mode 100644 index 0000000..a0f8225 --- /dev/null +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -0,0 +1,108 @@ +import { isFdiTooth, toFdi } from '../../common/fdi'; +import type { ToothIntent, UnresolvedItem } from './voice.types'; + +export type ToothResolution = { + /** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */ + teeth: string[]; + unresolved: UnresolvedItem[]; +}; + +/** 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() : ''; +} + +/** + * Resolve one spoken tooth reference to an FDI code, or null. + * + * Never guesses and never clamps: a position of 9, a deciduous tooth, or a malformed + * intent resolves to null so the caller can surface it as "not understood" rather than + * silently selecting a neighbouring tooth. + */ +export function resolveToothIntent(intent: ToothIntent): string | null { + if (!intent || typeof intent !== 'object') return null; + + if (intent.kind === 'explicit') { + const fdi = normalizedFdi(intent); + return isFdiTooth(fdi) ? fdi : null; + } + + if (intent.kind === 'positional') { + if (intent.arch !== 'upper' && intent.arch !== 'lower') return null; + if (intent.side !== 'patient_right' && intent.side !== 'patient_left') + return null; + return toFdi(intent.arch, intent.side, intent.position); + } + + return null; +} + +function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { + if (!intent || typeof intent !== 'object') return 'malformed'; + + if (intent.kind === 'explicit') { + const fdi = normalizedFdi(intent); + // Quadrants 1-4 are permanent and would already have resolved, so a well-formed + // quadrant+position reaching here is quadrant 5-8: deciduous. Anything else is noise. + return /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed'; + } + + if (intent.kind === 'positional') { + const positionBad = + !Number.isInteger(intent.position) || + intent.position < 1 || + intent.position > 8; + return positionBad ? 'position_out_of_range' : 'malformed'; + } + + return 'malformed'; +} + +function spokenOf(intent: ToothIntent): string { + const spoken = (intent as { spoken?: unknown })?.spoken; + return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : ''; +} + +/** + * Resolve a list of spoken tooth references. + * + * Duplicates collapse — a clinician may name the same tooth twice in one sentence — and + * anything unresolvable is reported rather than dropped, so the review sheet can show the + * user exactly which words were not understood. + */ +export function resolveToothIntents( + intents: readonly ToothIntent[], +): ToothResolution { + const teeth = new Set(); + const unresolved: UnresolvedItem[] = []; + const seenUnresolved = new Set(); + + // Not `intents ?? []`: a model may return an object or a number here, and a + // non-iterable must degrade like any other malformed shape rather than throw. + const list: readonly ToothIntent[] = Array.isArray(intents) + ? (intents as readonly ToothIntent[]) + : []; + + for (const intent of list) { + const fdi = resolveToothIntent(intent); + if (fdi) { + teeth.add(fdi); + continue; + } + const reason = unresolvedReason(intent); + const spoken = spokenOf(intent); + // Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost + // references would collapse into one blank review row and a tooth would vanish. + if (spoken) { + const key = `${spoken}::${reason}`; + if (seenUnresolved.has(key)) continue; + seenUnresolved.add(key); + } + unresolved.push({ spoken, reason }); + } + + return { teeth: [...teeth].sort(), unresolved }; +} diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts new file mode 100644 index 0000000..e94abda --- /dev/null +++ b/backend/src/modules/voice/voice.types.ts @@ -0,0 +1,77 @@ +import type { Arch, PatientSide } from '../../common/fdi'; + +/** + * What the extraction model is allowed to return. + * + * The model emits *intents*, never resolved values: no FDI codes, no ISO dates. Pure, + * unit-tested resolvers turn intents into domain values, so the two highest-consequence + * mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful. + */ + +/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */ +export type ToothIntent = + | { kind: 'explicit'; fdi: string; spoken: string } + | { + kind: 'positional'; + arch: Arch; + side: PatientSide; + /** 1 = central incisor … 8 = third molar. */ + position: number; + spoken: string; + }; + +/** A spoken deadline. The model never does calendar arithmetic. */ +export type DueIntent = + | { kind: 'weekday'; weekday: Weekday; which: 'this' | 'next' } + | { kind: 'offset'; unit: 'day' | 'week' | 'month'; amount: number } + | { kind: 'jalali'; jy: number; jm: number; jd: number } + | { kind: 'gregorian'; y: number; m: number; d: number }; + +export const WEEKDAYS = [ + 'saturday', + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', +] as const; + +export type Weekday = (typeof WEEKDAYS)[number]; + +/** Two teeth defining an inclusive connected (bridge) span. */ +export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent }; + +export type ProsthesisIntent = { + /** Catalog code applied to every tooth unless overridden. */ + defaultType: string | null; + overrides: { tooth: ToothIntent; type: string }[]; +}; + +export type VoiceIntent = { + treatmentType: string | null; + teeth: ToothIntent[]; + connectedSpans: ConnectedSpanIntent[]; + comment: string | null; + prosthesis: ProsthesisIntent | null; + /** Must be one of the linked-lab ids supplied in the prompt, or null. */ + labId: string | null; + /** False when the spoken name only approximately matched — the UI then requires an explicit tick. */ + labMatchExact: boolean; + due: DueIntent | null; +}; + +/** Why a spoken item could not be turned into a domain value. Shown to the user. */ +export type UnresolvedReason = + | 'not_permanent_tooth' + | 'position_out_of_range' + | 'malformed' + | 'span_not_same_arch' + | 'unknown_catalog_code' + | 'invalid_date'; + +export type UnresolvedItem = { + /** The transcript span that could not be resolved, so the user can see what was heard. */ + spoken: string; + reason: UnresolvedReason; +};