diff --git a/backend/src/common/fdi.spec.ts b/backend/src/common/fdi.spec.ts index b3128b2..3910963 100644 --- a/backend/src/common/fdi.spec.ts +++ b/backend/src/common/fdi.spec.ts @@ -1,5 +1,6 @@ import { areArchNeighbors, + sortInArchOrder, FDI_TOOTH_IDS, isFdiTooth, sameArch, @@ -122,4 +123,22 @@ describe('FDI geometry', () => { expect(teethBetweenInclusive('99', '14')).toBeNull(); }); }); + describe('sortInArchOrder', () => { + it('sorts along the arch rather than lexically', () => { + expect(sortInArchOrder(['14', '16', '15'])).toEqual(['16', '15', '14']); + }); + + it('places 11 beside 21 across the midline', () => { + expect(sortInArchOrder(['21', '11', '12'])).toEqual(['12', '11', '21']); + }); + + it('is a no-op for an empty or single-tooth list', () => { + expect(sortInArchOrder([])).toEqual([]); + expect(sortInArchOrder(['14'])).toEqual(['14']); + }); + + it('does not drop teeth it cannot place', () => { + expect(sortInArchOrder(['99', '14']).sort()).toEqual(['14', '99']); + }); + }); }); diff --git a/backend/src/common/fdi.ts b/backend/src/common/fdi.ts index 0112596..e60ba3c 100644 --- a/backend/src/common/fdi.ts +++ b/backend/src/common/fdi.ts @@ -118,3 +118,18 @@ export function toFdi( const code = `${quadrant}${position}`; return FDI_TOOTH_IDS.has(code) ? code : null; } + +/** + * Sort teeth along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside + * 21 across the midline. Teeth from another arch (or unknown) sort to the end, stably. + */ +export function sortInArchOrder(teeth: readonly string[]): string[] { + if (teeth.length === 0) return []; + const arch = teeth.map((t) => archOrder(t)).find((a) => a !== null) ?? null; + if (!arch) return [...teeth]; + const indexOf = (tooth: string) => { + const i = arch.indexOf(tooth); + return i === -1 ? Number.MAX_SAFE_INTEGER : i; + }; + return [...teeth].sort((a, b) => indexOf(a) - indexOf(b)); +} diff --git a/backend/src/modules/voice/due-date.resolver.spec.ts b/backend/src/modules/voice/due-date.resolver.spec.ts index 90f7fd7..6b7b5cf 100644 --- a/backend/src/modules/voice/due-date.resolver.spec.ts +++ b/backend/src/modules/voice/due-date.resolver.spec.ts @@ -239,6 +239,24 @@ describe('resolveDueDate', () => { }); }); + it('treats an object with no kind as no deadline, not a lost one', () => { + // A blank "heard but lost" row in front of a clinician who never mentioned a + // deadline is worse than saying nothing. + expect(resolveDueDate({} as never, SATURDAY)).toEqual({ + dueDate: null, + unresolved: null, + }); + }); + + it('flags an unrecognised kind and names it', () => { + const result = resolveDueDate({ kind: 'lunar_month' } as never, SATURDAY); + expect(result.dueDate).toBeNull(); + expect(result.unresolved).toEqual({ + spoken: 'lunar_month', + reason: 'invalid_date', + }); + }); + it('flags a non-object deadline instead of silently dropping it', () => { // A bare string is a deadline we failed to parse, not an absent one — the clinician // must see that something was heard and lost. diff --git a/backend/src/modules/voice/due-date.resolver.ts b/backend/src/modules/voice/due-date.resolver.ts index e833d8c..7019610 100644 --- a/backend/src/modules/voice/due-date.resolver.ts +++ b/backend/src/modules/voice/due-date.resolver.ts @@ -87,8 +87,12 @@ function describe(intent: DueIntent): string { return `${intent.jy}/${intent.jm}/${intent.jd}`; case 'gregorian': return `${intent.y}-${intent.m}-${intent.d}`; - default: - return ''; + default: { + // Reaching here means an unrecognised `kind`, which resolveDueDate has already + // established is a string — echo it so the review row names what was heard. + const kind = (intent as { kind?: unknown })?.kind; + return typeof kind === 'string' ? kind : ''; + } } } @@ -162,6 +166,12 @@ export function resolveDueDate( if (typeof intent !== 'object') { return unresolved(String(intent).slice(0, 120)); } + // An object carrying no `kind` at all says nothing about a deadline; flagging it would + // put a blank "heard but lost" row in front of a clinician who never mentioned one. An + // object with an *unrecognised* kind did try to say something, and is flagged below. + if (typeof (intent as { kind?: unknown }).kind !== 'string') { + return { dueDate: null, unresolved: null }; + } if (!isRealCivilDate(todayIso)) { return unresolved(describe(intent)); } diff --git a/backend/src/modules/voice/extraction.resolver.spec.ts b/backend/src/modules/voice/extraction.resolver.spec.ts new file mode 100644 index 0000000..aaecb36 --- /dev/null +++ b/backend/src/modules/voice/extraction.resolver.spec.ts @@ -0,0 +1,353 @@ +import { + resolveConnectedSpans, + resolveProsthesis, + resolveVoiceIntent, + type ResolveContext, +} from './extraction.resolver'; +import type { ToothIntent, VoiceIntent } from './voice.types'; + +const tooth = (fdi: string, spoken = fdi): ToothIntent => ({ + kind: 'explicit', + fdi, + spoken, +}); + +const CTX: ResolveContext = { + todayIso: '2025-10-11', + treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']), + prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']), + linkedLabIds: new Set(['lab-sina', 'lab-mehr']), +}; + +describe('resolveConnectedSpans', () => { + it('selects the teeth between the endpoints, which were never named', () => { + // "a bridge from 14 to 16" must select 15 too. + const result = resolveConnectedSpans( + [{ from: tooth('14'), to: tooth('16') }], + [], + ); + expect(result.teeth).toEqual(['14', '15', '16']); + expect(result.groups).toEqual([ + { groupId: 'voice-c1', kind: 'connected', teeth: ['16', '15', '14'] }, + ]); + }); + + it('orders group teeth along the arch, not lexically', () => { + const result = resolveConnectedSpans( + [{ from: tooth('16'), to: tooth('14') }], + [], + ); + expect(result.groups[0].teeth).toEqual(['16', '15', '14']); + }); + + it('spans the midline', () => { + const result = resolveConnectedSpans( + [{ from: tooth('12'), to: tooth('22') }], + [], + ); + expect(result.groups[0].teeth).toEqual(['12', '11', '21', '22']); + }); + + it('merges overlapping spans into one bridge', () => { + const result = resolveConnectedSpans( + [ + { from: tooth('14'), to: tooth('16') }, + { from: tooth('15'), to: tooth('17') }, + ], + [], + ); + const connected = result.groups.filter((g) => g.kind === 'connected'); + expect(connected).toHaveLength(1); + expect(connected[0].teeth).toEqual(['17', '16', '15', '14']); + }); + + it('gives loose teeth their own single groups', () => { + const result = resolveConnectedSpans( + [{ from: tooth('14'), to: tooth('15') }], + ['26'], + ); + expect(result.groups).toEqual([ + { groupId: 'voice-c1', kind: 'connected', teeth: ['15', '14'] }, + { groupId: 'voice-s-26', kind: 'single', teeth: ['26'] }, + ]); + }); + + it('never produces a one-tooth connected group', () => { + const result = resolveConnectedSpans( + [{ from: tooth('14'), to: tooth('14') }], + [], + ); + expect(result.groups).toEqual([ + { groupId: 'voice-s-14', kind: 'single', teeth: ['14'] }, + ]); + expect(result.unresolved).toEqual([]); + }); + + it('reports a cross-arch span rather than guessing', () => { + const result = resolveConnectedSpans( + [{ from: tooth('14', 'چهارده'), to: tooth('44', 'چهل و چهار') }], + [], + ); + expect(result.groups).toEqual([]); + expect(result.unresolved).toEqual([ + { spoken: 'چهارده → چهل و چهار', reason: 'span_not_same_arch' }, + ]); + }); + + it('reports a span with an unresolvable endpoint', () => { + const result = resolveConnectedSpans( + [{ from: tooth('14'), to: tooth('99') }], + [], + ); + expect(result.unresolved[0].reason).toBe('malformed'); + }); + + it('survives a non-array', () => { + expect(resolveConnectedSpans(undefined as never, ['14']).teeth).toEqual([ + '14', + ]); + }); +}); + +describe('resolveProsthesis', () => { + const allowed = CTX.prosthesisTypeCodes; + + it('expands the default across every tooth', () => { + const result = resolveProsthesis( + { defaultType: 'monolithic_zirconia', overrides: [] }, + ['14', '15'], + allowed, + ); + expect(result.prosthesis?.byTooth).toEqual({ + '14': 'monolithic_zirconia', + '15': 'monolithic_zirconia', + }); + expect(result.prosthesis?.complete).toBe(true); + }); + + it('applies per-tooth overrides on top of the default', () => { + const result = resolveProsthesis( + { + defaultType: 'monolithic_zirconia', + overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }], + }, + ['14', '26'], + allowed, + ); + expect(result.prosthesis?.byTooth).toEqual({ + '14': 'monolithic_zirconia', + '26': 'pfm_crown', + }); + expect(result.prosthesis?.complete).toBe(true); + }); + + it('marks the map incomplete when a tooth ends up untyped', () => { + // Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch. + const result = resolveProsthesis( + { + defaultType: null, + overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }], + }, + ['14', '15'], + allowed, + ); + expect(result.prosthesis?.complete).toBe(false); + expect(result.prosthesis?.missingTeeth).toEqual(['15']); + }); + + it('rejects a catalog code the clinic does not have', () => { + const result = resolveProsthesis( + { defaultType: 'gold_foil', overrides: [] }, + ['14'], + allowed, + ); + // Nothing usable was said, so there is no prosthesis to show — not an empty one. + expect(result.prosthesis).toBeNull(); + expect(result.unresolved).toEqual([ + { spoken: 'gold_foil', reason: 'unknown_catalog_code' }, + ]); + }); + + it('ignores an override for a tooth that is not selected', () => { + const result = resolveProsthesis( + { + defaultType: 'monolithic_zirconia', + overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }], + }, + ['14'], + allowed, + ); + expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' }); + expect(result.unresolved).toEqual([ + { spoken: 'سی و هفت', reason: 'tooth_not_selected' }, + ]); + }); + + it('reports no prosthesis at all when the object carries nothing usable', () => { + // An empty-but-present map would paint a plain restoration with a fabricated + // "incomplete, cannot ship" warning. + for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) { + expect( + resolveProsthesis(empty, ['14', '15'], allowed).prosthesis, + ).toBeNull(); + } + }); + + it('reports no prosthesis when there are no teeth to type', () => { + const result = resolveProsthesis( + { defaultType: 'monolithic_zirconia', overrides: [] }, + [], + allowed, + ); + expect(result.prosthesis).toBeNull(); + }); + + it('distinguishes a tooth it could not understand from one that is not selected', () => { + // Different corrective actions: add the tooth, versus repeat yourself. + const result = resolveProsthesis( + { + defaultType: 'monolithic_zirconia', + overrides: [ + { + tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' }, + type: 'pfm_crown', + }, + ], + }, + ['14'], + allowed, + ); + expect(result.unresolved).toEqual([ + { spoken: 'نود و نه', reason: 'malformed' }, + ]); + }); + + it('returns null when no prosthesis was spoken', () => { + expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull(); + }); +}); + +describe('resolveVoiceIntent', () => { + const base: VoiceIntent = { + treatmentType: 'restoration', + teeth: [tooth('14'), tooth('15')], + connectedSpans: [], + comment: ' حساسیت به سرما ', + prosthesis: null, + labId: null, + labMatchExact: false, + due: null, + }; + + it('composes a plain restoration', () => { + const result = resolveVoiceIntent(base, CTX); + expect(result.treatmentType).toBe('restoration'); + expect(result.teeth).toEqual(['14', '15']); + expect(result.comment).toBe('حساسیت به سرما'); + expect(result.prosthesis).toBeNull(); + expect(result.unresolved).toEqual([]); + }); + + it('rejects a treatment type outside the catalog', () => { + const result = resolveVoiceIntent( + { ...base, treatmentType: 'teeth_whitening' }, + CTX, + ); + expect(result.treatmentType).toBeNull(); + expect(result.unresolved).toContainEqual({ + spoken: 'teeth_whitening', + reason: 'unknown_catalog_code', + }); + }); + + it('drops a lab id the clinic is not linked to', () => { + // Shipping to a lab the clinic never named is worse than shipping nowhere. + const result = resolveVoiceIntent( + { ...base, labId: 'lab-elsewhere', labMatchExact: true }, + CTX, + ); + expect(result.labId).toBeNull(); + expect(result.labMatchExact).toBe(false); + }); + + it('keeps a linked lab and its exactness flag', () => { + const result = resolveVoiceIntent( + { ...base, labId: 'lab-sina', labMatchExact: true }, + CTX, + ); + expect(result.labId).toBe('lab-sina'); + expect(result.labMatchExact).toBe(true); + }); + + it('reports a hallucinated lab rather than dropping it silently', () => { + // A near-miss lab id must not look identical to "no lab was spoken". + const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX); + expect(result.unresolved).toContainEqual({ + spoken: 'lab-elsewhere', + reason: 'unknown_catalog_code', + }); + }); + + it('never reports an inexact match as exact when the lab was dropped', () => { + const result = resolveVoiceIntent( + { ...base, labId: null, labMatchExact: true }, + CTX, + ); + expect(result.labMatchExact).toBe(false); + }); + + it('applies prosthesis over the span-expanded tooth set', () => { + const result = resolveVoiceIntent( + { + ...base, + treatmentType: 'prosthesis', + teeth: [tooth('14')], + connectedSpans: [{ from: tooth('14'), to: tooth('16') }], + prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] }, + }, + CTX, + ); + // 15 was never spoken but is part of the bridge, so it must carry a type too. + expect(result.teeth).toEqual(['14', '15', '16']); + expect(result.prosthesis?.complete).toBe(true); + expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([ + '14', + '15', + '16', + ]); + }); + + it('resolves a due date through the same context', () => { + const result = resolveVoiceIntent( + { ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } }, + CTX, + ); + expect(result.dueDate).toBe('2025-10-16'); + }); + + it('collects unresolved items from every stage', () => { + const result = resolveVoiceIntent( + { + ...base, + treatmentType: 'nope', + teeth: [tooth('51', 'شیری')], + connectedSpans: [{ from: tooth('14'), to: tooth('44') }], + due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 }, + }, + CTX, + ); + const reasons = result.unresolved.map((u) => u.reason).sort(); + expect(reasons).toEqual([ + 'invalid_date', + 'not_permanent_tooth', + 'span_not_same_arch', + 'unknown_catalog_code', + ]); + }); + + it('treats an empty comment as absent', () => { + expect( + resolveVoiceIntent({ ...base, comment: ' ' }, CTX).comment, + ).toBeNull(); + }); +}); diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts new file mode 100644 index 0000000..e920475 --- /dev/null +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -0,0 +1,305 @@ +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; + 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); + 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, + }; +} diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index e94abda..fc6ef4a 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -68,6 +68,7 @@ export type UnresolvedReason = | 'malformed' | 'span_not_same_arch' | 'unknown_catalog_code' + | 'tooth_not_selected' | 'invalid_date'; export type UnresolvedItem = {