From 1be735a7ab391c49027c0cc6c18c18045f105644 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:02:39 +0800 Subject: [PATCH] fix(voice): say the quadrant is missing instead of "could not be read" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "ترمیم برای دندون دو" set the treatment type but reported the tooth as unreadable. Nothing was misheard: position 2 arrived intact, with no quadrant, because none was spoken — four teeth carry position 2 and the resolver correctly refused to pick one. Only the label was wrong, and it sent the clinician looking for a transcription fault. Adds a tooth_missing_quadrant reason that names what is missing and shows how to say it ("دو بالا راست"), and tells the model explicitly to report a quadrant-less number with arch and side null rather than guessing. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/voice/extraction.prompt.ts | 4 ++ .../voice/tooth-intent.resolver.spec.ts | 51 +++++++++++++++++++ .../modules/voice/tooth-intent.resolver.ts | 9 +++- backend/src/modules/voice/voice.types.ts | 2 + frontend/messages/en.json | 1 + frontend/messages/fa.json | 1 + frontend/messages/nl.json | 1 + frontend/src/types/voice.ts | 1 + 8 files changed, 69 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/voice/extraction.prompt.ts b/backend/src/modules/voice/extraction.prompt.ts index 6c07a23..86a415f 100644 --- a/backend/src/modules/voice/extraction.prompt.ts +++ b/backend/src/modules/voice/extraction.prompt.ts @@ -7,6 +7,7 @@ const LOCALE_NOTES: Record = { '"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.', 'Digits may appear in Persian or Latin script. Two-digit FDI notation ("یک چهار") does', 'occur — use the "fdi" field only for that.', + 'A bare "دندون دو" carries no quadrant: report position 2 with arch and side null.', ].join(' '), nl: [ 'The clinician is speaking Dutch and uses FDI notation, which is standard in the', @@ -50,6 +51,9 @@ export function buildExtractionPrompt( ' what was heard.', '6. If you are unsure about a value, use null. A missing field is recoverable; a wrong', ' one is not.', + '7. A tooth number spoken WITHOUT a quadrant ("دندون دو", "tooth two") does not identify', + ' a tooth — four teeth carry that position. Still report it: set "position" and leave', + ' "arch" and "side" null. Never pick a quadrant that was not said.', '', localeNote, '', diff --git a/backend/src/modules/voice/tooth-intent.resolver.spec.ts b/backend/src/modules/voice/tooth-intent.resolver.spec.ts index b9cd7cf..efaa894 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.spec.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.spec.ts @@ -150,6 +150,57 @@ describe('resolveToothIntents', () => { } }); + it('says the quadrant is missing rather than blaming the words', () => { + // Regression: "ترمیم برای دندون دو" reported "could not be read", sending the + // clinician to look for a transcription fault. Position 2 was understood fine — + // what is missing is the quadrant, and four teeth carry position 2. + const bare = { + kind: 'positional', + arch: null, + side: null, + position: 2, + spoken: 'دندون دو', + } as unknown as ToothIntent; + + const result = resolveToothIntents([bare]); + expect(result.teeth).toEqual([]); + expect(result.unresolved).toEqual([ + { spoken: 'دندون دو', reason: 'tooth_missing_quadrant' }, + ]); + }); + + it('reports a missing quadrant for a half-specified tooth too', () => { + // "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess. + for (const half of [ + { arch: 'upper', side: null }, + { arch: null, side: 'patient_right' }, + ]) { + const result = resolveToothIntents([ + { + kind: 'positional', + ...half, + position: 2, + spoken: 'دو', + } as unknown as ToothIntent, + ]); + expect(result.unresolved[0].reason).toBe('tooth_missing_quadrant'); + } + }); + + it('still calls an out-of-range position out of range when the quadrant is missing', () => { + // Position wins: "nine" is wrong however completely it was said. + const result = resolveToothIntents([ + { + kind: 'positional', + arch: null, + side: null, + position: 9, + spoken: 'نه', + } as unknown as ToothIntent, + ]); + expect(result.unresolved[0].reason).toBe('position_out_of_range'); + }); + it('keeps unresolved items separate when the model omits the spoken span', () => { // Without `spoken` these are indistinguishable; collapsing them would hide a lost tooth. const result = resolveToothIntents([ diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts index a0f8225..4c3d48d 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -55,7 +55,14 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { !Number.isInteger(intent.position) || intent.position < 1 || intent.position > 8; - return positionBad ? 'position_out_of_range' : 'malformed'; + if (positionBad) return 'position_out_of_range'; + // The position was understood, so the words were not the problem: the speaker never + // said which quadrant. "دندون دو" names four teeth at once, and telling the + // clinician it "could not be read" would send them looking for 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'; diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index fc6ef4a..7d3b3f3 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -65,6 +65,8 @@ export type VoiceIntent = { export type UnresolvedReason = | 'not_permanent_tooth' | 'position_out_of_range' + /** A position was understood but no quadrant was spoken — four teeth match. */ + | 'tooth_missing_quadrant' | 'malformed' | 'span_not_same_arch' | 'unknown_catalog_code' diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fea502a..80c2403 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -914,6 +914,7 @@ "voiceUnresolved": { "not_permanent_tooth": "not a permanent tooth", "position_out_of_range": "not a valid tooth position", + "tooth_missing_quadrant": "quadrant not said — e.g. “upper right two”", "malformed": "could not be read", "span_not_same_arch": "a bridge cannot span both jaws", "unknown_catalog_code": "not in this clinic’s list", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index bc80145..d5ff320 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -915,6 +915,7 @@ "voiceUnresolved": { "not_permanent_tooth": "دندان دائمی نیست", "position_out_of_range": "شماره دندان معتبر نیست", + "tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»", "malformed": "قابل خواندن نبود", "span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد", "unknown_catalog_code": "در فهرست این مطب نیست", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index d2e87e6..a5d055d 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -914,6 +914,7 @@ "voiceUnresolved": { "not_permanent_tooth": "geen blijvend element", "position_out_of_range": "geen geldige elementpositie", + "tooth_missing_quadrant": "kwadrant niet genoemd — bijv. “rechtsboven twee”", "malformed": "kon niet worden gelezen", "span_not_same_arch": "een brug kan niet over beide kaken lopen", "unknown_catalog_code": "staat niet in de lijst van deze praktijk", diff --git a/frontend/src/types/voice.ts b/frontend/src/types/voice.ts index 3360839..8488066 100644 --- a/frontend/src/types/voice.ts +++ b/frontend/src/types/voice.ts @@ -5,6 +5,7 @@ import type { FdiToothId, ToothSelectionGroup } from '@/types/treatment'; export type VoiceUnresolvedReason = | 'not_permanent_tooth' | 'position_out_of_range' + | 'tooth_missing_quadrant' | 'malformed' | 'span_not_same_arch' | 'unknown_catalog_code'