diff --git a/backend/src/modules/voice/extraction.prompt.ts b/backend/src/modules/voice/extraction.prompt.ts index cb15942..ee05893 100644 --- a/backend/src/modules/voice/extraction.prompt.ts +++ b/backend/src/modules/voice/extraction.prompt.ts @@ -108,6 +108,8 @@ export function buildExtractionPrompt( ' "spoken", so the clinician can see what was heard.', '5. If you are unsure about a value, use null. A missing field is recoverable; a wrong', ' one is not.', + '6. A whole jaw is not a tooth. Never put a jaw in the "teeth" list. A jaw appears only', + ' as a prosthesis[].targets entry with "arch" set and "position" null.', '', 'TOOTH NUMBERS', 'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as', diff --git a/backend/src/modules/voice/extraction.resolver.spec.ts b/backend/src/modules/voice/extraction.resolver.spec.ts index 55dc36d..2900a8e 100644 --- a/backend/src/modules/voice/extraction.resolver.spec.ts +++ b/backend/src/modules/voice/extraction.resolver.spec.ts @@ -601,6 +601,84 @@ describe('resolveVoiceIntent', () => { expect(result.treatmentType).toBe('restoration'); }); + it('does not treat a spoken jaw as a broken tooth', () => { + // "یه کامپلیت دنچر برای فک بالا" — the model names the jaw in `teeth` as well as in the + // assignment target. The arch reached the form correctly, but the duplicate reported + // `position_out_of_range`, so the sheet asked which tooth was meant. No tooth was said. + const result = resolveVoiceIntent( + { + ...base, + treatmentType: null, + teeth: [positional({ arch: 'upper', spoken: 'فک بالا' })], + prosthesis: [ + { + targets: [positional({ arch: 'upper', spoken: 'فک بالا' })], + types: ['complete_denture'], + spoken: 'یه کامپلیت دنچر برای فک بالا', + }, + ], + }, + CTX, + ); + expect(result.teeth).toEqual([]); + expect(result.unresolved).toEqual([]); + expect(result.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]); + expect(result.treatmentType).toBe('prosthesis'); + }); + + it('drops a both-jaws reference from the teeth list too', () => { + const result = resolveVoiceIntent( + { + ...base, + teeth: [positional({ arch: 'both', spoken: 'هر دو فک' })], + }, + CTX, + ); + expect(result.teeth).toEqual([]); + expect(result.unresolved).toEqual([]); + }); + + it('still reports a tooth whose position is out of range', () => { + // The jaw filter must not swallow a real fault: a position was given, and it is wrong. + const result = resolveVoiceIntent( + { + ...base, + teeth: [ + positional({ + arch: 'upper', + side: 'patient_right', + position: 9, + spoken: 'دندون نه', + }), + ], + }, + CTX, + ); + expect(result.teeth).toEqual([]); + expect(result.unresolved).toContainEqual({ + spoken: 'دندون نه', + reason: 'position_out_of_range', + }); + }); + + it('still offers candidates for a tooth described without its quadrant', () => { + // arch + position, no side: a real tooth, under-specified. Must survive the filter. + const result = resolveVoiceIntent( + { + ...base, + teeth: [positional({ arch: 'upper', position: 2, spoken: 'دو بالا' })], + }, + CTX, + ); + expect(result.unresolved).toContainEqual( + expect.objectContaining({ + spoken: 'دو بالا', + reason: 'tooth_missing_quadrant', + candidates: ['12', '22'], + }), + ); + }); + it('resolves a due date through the same context', () => { const result = resolveVoiceIntent( { ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } }, diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts index dce2bd6..3be229b 100644 --- a/backend/src/modules/voice/extraction.resolver.ts +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -481,6 +481,25 @@ export function resolveProsthesisAssignment( }; } +/** + * A whole jaw is not a tooth, and the model often names one in `teeth` as well as in the + * prosthesis target it belongs to. An arch with no position then reaches `unresolvedReason`, + * which reports `position_out_of_range` — a range fault for a value that was never a number — + * and the sheet asks the clinician to repair a tooth nobody said. The jaw already reaches the + * form through its assignment, so drop the duplicate instead. + * + * A position that IS given stays: arch + position without a side is a real tooth described + * without its quadrant, and must keep offering its candidate chips. + */ +function isJawReference(intent: ToothIntent): boolean { + if (intent?.kind !== 'positional') return false; + const archGiven = + intent.arch === 'upper' || + intent.arch === 'lower' || + intent.arch === 'both'; + return archGiven && !Number.isInteger(intent.position); +} + /** Compose every resolver into the payload the review sheet renders. */ export function resolveVoiceIntent( intent: VoiceIntent, @@ -488,7 +507,9 @@ export function resolveVoiceIntent( ): ResolvedExtraction { const unresolved: UnresolvedItem[] = []; - const toothResult = resolveToothIntents(intent?.teeth ?? []); + const toothResult = resolveToothIntents( + (intent?.teeth ?? []).filter((tooth) => !isJawReference(tooth)), + ); unresolved.push(...toothResult.unresolved); const spanResult = resolveConnectedSpans( diff --git a/backend/src/modules/voice/extraction.wire.ts b/backend/src/modules/voice/extraction.wire.ts index ae71f40..9ed850b 100644 --- a/backend/src/modules/voice/extraction.wire.ts +++ b/backend/src/modules/voice/extraction.wire.ts @@ -135,7 +135,13 @@ export const VOICE_INTENT_JSON_SCHEMA = { type: ['string', 'null'], description: 'A treatment type CODE from the supplied list, or null.', }, - teeth: { type: 'array', items: TOOTH_SCHEMA }, + teeth: { + type: 'array', + description: + 'Individual teeth only. A whole jaw NEVER belongs here — put it in ' + + 'prosthesis[].targets with "arch" set and "position" null.', + items: TOOTH_SCHEMA, + }, connectedSpans: { type: 'array', description: 'Bridges / splinted units. Endpoints inclusive.',