From 76d929de3abdcbbc1d4673db3cd6b693ea430adc Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 10 Sep 2026 16:56:54 +0800 Subject: [PATCH] fix(voice): stop reading a dictated jaw as a broken tooth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "یه کامپلیت دنچر برای فک بالا میخوام" resolved the upper arch and the complete denture correctly, but the review sheet also showed position_out_of_range against «فک بالا» and asked which tooth was meant. No tooth was said. The model names the jaw in the top-level `teeth` array as well as in the prosthesis target it belongs to. resolveVoiceIntent passed that array straight to resolveToothIntents, extraction.wire.ts turns `position: null` into NaN, and unresolvedReason tests positionBad first — so it reported a range fault for a value that was never a number, before ever reaching the quadrant branch. resolveVoiceIntent now drops jaw-shaped entries — an arch with no usable position — before the tooth resolver sees them. The jaw already reaches the form through its assignment, so the duplicate carries no information worth reporting. An entry that DOES give a position survives: arch plus position without a side is a real tooth described without its quadrant, and must keep offering its candidate chips. Two invitations removed as well, both ours: the `teeth` property in VOICE_INTENT_JSON_SCHEMA had no description at all, and no prompt rule said a jaw must stay out of it, while TOOTH_SCHEMA — shared with prosthesis[].targets — describes jaws as acceptable. Adds the description and HARD RULE 6. Four tests. The two jaw cases fail without the filter; the out-of-range and missing-quadrant cases pass either way and exist to prove the filter does not over-reach. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/voice/extraction.prompt.ts | 2 + .../modules/voice/extraction.resolver.spec.ts | 78 +++++++++++++++++++ .../src/modules/voice/extraction.resolver.ts | 23 +++++- backend/src/modules/voice/extraction.wire.ts | 8 +- 4 files changed, 109 insertions(+), 2 deletions(-) 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.',