import { sameArch, sortInArchOrder, teethBetweenInclusive, } from '../../common/fdi'; import { resolveDueDate } from './due-date.resolver'; import { resolveToothIntent, resolveToothIntents, } from './tooth-intent.resolver'; import type { ConnectedSpanIntent, ProsthesisIntent, ToothIntent, UnresolvedItem, VoiceIntent, } from './voice.types'; export type ResolvedToothGroup = { groupId: string; kind: 'connected' | 'single'; teeth: string[]; }; export type ResolvedProsthesis = { /** FDI code → prosthesis type code. */ byTooth: Record; /** * True when every selected tooth carries a code. A prosthesis detail cannot be shipped * otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap * here rather than letting it fail at dispatch. */ complete: boolean; missingTeeth: string[]; }; export type ResolvedExtraction = { treatmentType: string | null; teeth: string[]; toothSelectionGroups: ResolvedToothGroup[]; comment: string | null; prosthesis: ResolvedProsthesis | null; labId: string | null; labMatchExact: boolean; dueDate: string | null; unresolved: UnresolvedItem[]; }; export type ResolveContext = { todayIso: string; /** JS weekday index the clinician's week starts on — see weekStartForLocale. */ weekStartJs: number; treatmentTypeCodes: ReadonlySet; prosthesisTypeCodes: ReadonlySet; linkedLabIds: ReadonlySet; }; function spokenOf(intent: ToothIntent): string { const spoken = (intent as { spoken?: unknown })?.spoken; return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : ''; } /** A code the model returned is only usable if it exists in the catalog we supplied it. */ function resolveCatalogCode( value: unknown, allowed: ReadonlySet, ): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed && allowed.has(trimmed) ? trimmed : null; } /** Merge any span sets that share a tooth, so overlapping bridges become one group. */ function mergeOverlapping(sets: string[][]): string[][] { const merged: string[][] = []; for (const candidate of sets) { let current = [...candidate]; let index = 0; while (index < merged.length) { if (merged[index].some((tooth) => current.includes(tooth))) { current = [...new Set([...merged[index], ...current])]; merged.splice(index, 1); index = 0; continue; } index += 1; } merged.push(current); } return merged; } /** * Turn spoken bridge spans plus loose teeth into selection groups. * * Span teeth are added to the selection: saying "a bridge from 14 to 16" selects 15 even * though it was never named. A span whose endpoints are in different arches is impossible * and is reported rather than guessed at. A span that collapses to one tooth degrades to a * single — there is no such thing as a one-tooth bridge. */ export function resolveConnectedSpans( spans: readonly ConnectedSpanIntent[], selectedTeeth: readonly string[], ): { groups: ResolvedToothGroup[]; teeth: string[]; unresolved: UnresolvedItem[]; } { const unresolved: UnresolvedItem[] = []; const connectedSets: string[][] = []; // A span that collapses to one tooth still selected that tooth — it must not vanish. const loneSpanTeeth: string[] = []; const list: readonly ConnectedSpanIntent[] = Array.isArray(spans) ? (spans as readonly ConnectedSpanIntent[]) : []; for (const span of list) { const from = resolveToothIntent(span?.from); const to = resolveToothIntent(span?.to); const spoken = [spokenOf(span?.from), spokenOf(span?.to)] .filter(Boolean) .join(' → '); if (!from || !to) { unresolved.push({ spoken, reason: 'malformed' }); continue; } if (!sameArch(from, to)) { unresolved.push({ spoken, reason: 'span_not_same_arch' }); continue; } const between = teethBetweenInclusive(from, to); if (!between || between.length === 0) { unresolved.push({ spoken, reason: 'malformed' }); continue; } if (between.length === 1) { loneSpanTeeth.push(between[0]); continue; // degrades to a single, below } connectedSets.push(between); } const groups: ResolvedToothGroup[] = []; const claimed = new Set(); mergeOverlapping(connectedSets).forEach((set, i) => { const teeth = sortInArchOrder(set); teeth.forEach((tooth) => claimed.add(tooth)); groups.push({ groupId: `voice-c${i + 1}`, kind: 'connected', teeth }); }); const spanTeeth = [...groups.flatMap((g) => g.teeth), ...loneSpanTeeth]; const singles = [...new Set([...selectedTeeth, ...spanTeeth])] .filter((tooth) => !claimed.has(tooth)) .sort(); for (const tooth of singles) { groups.push({ groupId: `voice-s-${tooth}`, kind: 'single', teeth: [tooth], }); } return { groups, teeth: [...new Set([...selectedTeeth, ...spanTeeth])].sort(), unresolved, }; } /** * Expand a default prosthesis type across the selection, then apply per-tooth overrides. * * "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak, so the model names the type * once and overrides the exceptions. */ export function resolveProsthesis( intent: ProsthesisIntent | null | undefined, teeth: readonly string[], allowed: ReadonlySet, ): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } { if (!intent || typeof intent !== 'object') return { prosthesis: null, unresolved: [] }; const unresolved: UnresolvedItem[] = []; const defaultType = resolveCatalogCode(intent.defaultType, allowed); if (intent.defaultType != null && !defaultType) { unresolved.push({ spoken: String(intent.defaultType), reason: 'unknown_catalog_code', }); } const byTooth: Record = {}; const selected = new Set(teeth); if (defaultType) { for (const tooth of teeth) byTooth[tooth] = defaultType; } const overrides: ProsthesisIntent['overrides'] = Array.isArray( intent.overrides, ) ? intent.overrides : []; for (const override of overrides) { const tooth = resolveToothIntent(override?.tooth); const type = resolveCatalogCode(override?.type, allowed); const spoken = spokenOf(override?.tooth) || String(override?.type ?? ''); if (!tooth) { unresolved.push({ spoken, reason: 'malformed' }); continue; } // A tooth we understood perfectly well but which is not part of this detail. Saying // so is actionable ("add tooth 37, or drop it"); calling it malformed is not. if (!selected.has(tooth)) { unresolved.push({ spoken, reason: 'tooth_not_selected' }); continue; } if (!type) { unresolved.push({ spoken, reason: 'unknown_catalog_code' }); continue; } byTooth[tooth] = type; } // Nothing usable was said about prosthesis. Returning an empty-but-present map would // paint a plain restoration with a fabricated "incomplete, cannot ship" warning. if (Object.keys(byTooth).length === 0) { return { prosthesis: null, unresolved }; } const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]); return { prosthesis: { byTooth, complete: missingTeeth.length === 0, missingTeeth, }, unresolved, }; } /** Compose every resolver into the payload the review sheet renders. */ export function resolveVoiceIntent( intent: VoiceIntent, ctx: ResolveContext, ): ResolvedExtraction { const unresolved: UnresolvedItem[] = []; const toothResult = resolveToothIntents(intent?.teeth ?? []); unresolved.push(...toothResult.unresolved); const spanResult = resolveConnectedSpans( intent?.connectedSpans ?? [], toothResult.teeth, ); unresolved.push(...spanResult.unresolved); const treatmentType = resolveCatalogCode( intent?.treatmentType, ctx.treatmentTypeCodes, ); if (intent?.treatmentType != null && !treatmentType) { unresolved.push({ spoken: String(intent.treatmentType), reason: 'unknown_catalog_code', }); } const prosthesisResult = resolveProsthesis( intent?.prosthesis, spanResult.teeth, ctx.prosthesisTypeCodes, ); unresolved.push(...prosthesisResult.unresolved); const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs); if (due.unresolved) unresolved.push(due.unresolved); const comment = typeof intent?.comment === 'string' && intent.comment.trim() ? intent.comment.trim() : null; // A lab id the model invented is worse than none — it would ship a case to a lab the // clinic never named. Only ids from the list we supplied survive, and a rejected one is // reported: a hallucinated lab must not look identical to "no lab was spoken". const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds); if (intent?.labId != null && !labId) { unresolved.push({ spoken: String(intent.labId), reason: 'unknown_catalog_code', }); } return { treatmentType, teeth: spanResult.teeth, toothSelectionGroups: spanResult.groups, comment, prosthesis: prosthesisResult.prosthesis, labId, labMatchExact: labId ? intent?.labMatchExact === true : false, dueDate: due.dueDate, unresolved, }; }