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 }; }