import { isFdiTooth, normalizeFdiCode, toFdi, type Arch, type PatientSide, } from '../../common/fdi'; import type { ToothIntent, UnresolvedItem } from './voice.types'; const ARCHES: readonly Arch[] = ['upper', 'lower']; const SIDES: readonly PatientSide[] = ['patient_right', 'patient_left']; 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 { // The same normalisation the wire layer used to pick this branch, so the two agree. return normalizeFdiCode((intent as { fdi?: unknown }).fdi); } /** * Never guesses and never clamps: position 9, a deciduous tooth or a malformed intent all * resolve to null, so the caller surfaces "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; if (positionBad) return 'position_out_of_range'; // The position was understood; the quadrant was never said. "دندون دو" names four // teeth, so "could not be read" would send the clinician after the wrong fault. const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower'; const sideMissing = intent.side !== 'patient_right' && intent.side !== 'patient_left'; return archMissing || sideMissing ? 'tooth_missing_quadrant' : 'malformed'; } return 'malformed'; } /** * The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. Not * a guess: the full set of readings, for the clinician to choose from. */ function quadrantCandidates(intent: ToothIntent): string[] { if (intent.kind !== 'positional') return []; const arches = intent.arch === 'upper' || intent.arch === 'lower' ? [intent.arch] : ARCHES; const sides = intent.side === 'patient_right' || intent.side === 'patient_left' ? [intent.side] : SIDES; const codes: string[] = []; for (const arch of arches) { for (const side of sides) { const fdi = toFdi(arch, side, intent.position); if (fdi) codes.push(fdi); } } return codes.sort(); } function spokenOf(intent: ToothIntent): string { const spoken = (intent as { spoken?: unknown })?.spoken; return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : ''; } /** * Duplicates collapse; anything unresolvable is reported rather than dropped, so the sheet * can show 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); const candidates = reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : []; // Only dedupe what we can tell apart: without `spoken`, two lost references collapse // into one blank row and a tooth vanishes. Candidates are part of the identity. if (spoken) { const key = `${spoken}::${reason}::${candidates.join(',')}`; if (seenUnresolved.has(key)) continue; seenUnresolved.add(key); } unresolved.push( candidates.length > 0 ? { spoken, reason, candidates } : { spoken, reason }, ); } return { teeth: [...teeth].sort(), unresolved }; }