From 82fad4ac02b0b86befaff649c2df6bc4b5f8a553 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Fri, 21 Aug 2026 04:44:20 +0800 Subject: [PATCH] feat(backend): offer the candidate teeth for an unspecified quadrant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tooth the resolver refuses to guess at is currently a dead end: the sheet says the quadrant was missing and the clinician has to leave and find the tooth on the chart. But the readings are enumerable — "دو" is one of four teeth, "دو بالا" one of two — so unresolved items now carry them. Narrowed by whatever was actually said, so this stays a choice offered to the clinician rather than a guess made for them. Only tooth_missing_quadrant carries candidates; a wrong position or a deciduous tooth has nothing to choose between. Co-Authored-By: Claude Opus 5 (1M context) --- .../voice/tooth-intent.resolver.spec.ts | 39 +++++++++++++- .../modules/voice/tooth-intent.resolver.ts | 51 +++++++++++++++++-- backend/src/modules/voice/voice.types.ts | 7 +++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/voice/tooth-intent.resolver.spec.ts b/backend/src/modules/voice/tooth-intent.resolver.spec.ts index 6b95091..f145309 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.spec.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.spec.ts @@ -173,10 +173,47 @@ describe('resolveToothIntents', () => { const result = resolveToothIntents([bare]); expect(result.teeth).toEqual([]); expect(result.unresolved).toEqual([ - { spoken: 'دندون دو', reason: 'tooth_missing_quadrant' }, + { + spoken: 'دندون دو', + reason: 'tooth_missing_quadrant', + // Every reading of "position 2", for the clinician to pick from. + candidates: ['12', '22', '32', '42'], + }, ]); }); + it('narrows the candidates by whatever the clinician did say', () => { + const half = (arch: string | null, side: string | null) => + resolveToothIntents([ + { + kind: 'positional', + arch, + side, + position: 2, + spoken: 'دو', + } as unknown as ToothIntent, + ]).unresolved[0].candidates; + + expect(half('upper', null)).toEqual(['12', '22']); + expect(half('lower', null)).toEqual(['32', '42']); + // Quadrant 1 is the patient's upper right, 4 the lower right. + expect(half(null, 'patient_right')).toEqual(['12', '42']); + expect(half(null, 'patient_left')).toEqual(['22', '32']); + }); + + it('offers no candidates for a reason a choice cannot settle', () => { + // Nothing to choose between when the position itself was wrong, or the tooth is + // deciduous — offering chips there would invent options. + for (const intent of [ + positional('upper', 'patient_right', 9, 'نه'), + explicit('51', 'شیری'), + ]) { + expect( + resolveToothIntents([intent]).unresolved[0].candidates, + ).toBeUndefined(); + } + }); + 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 [ diff --git a/backend/src/modules/voice/tooth-intent.resolver.ts b/backend/src/modules/voice/tooth-intent.resolver.ts index 7b5e151..f021f16 100644 --- a/backend/src/modules/voice/tooth-intent.resolver.ts +++ b/backend/src/modules/voice/tooth-intent.resolver.ts @@ -1,6 +1,15 @@ -import { isFdiTooth, normalizeFdiCode, toFdi } from '../../common/fdi'; +import { + isFdiTooth, + normalizeFdiCode, + toFdi, + type Arch, + type PatientSide, +} from '../../common/fdi'; import type { ToothIntent, UnresolvedItem } from './voice.types'; +const ARCHES: readonly Arch[] = ['upper', 'lower']; +const SIDES: readonly PatientSide[] = ['patient_right', 'patient_left']; + export type ToothResolution = { /** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */ teeth: string[]; @@ -68,6 +77,32 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] { return 'malformed'; } +/** + * The teeth still consistent with what *was* heard. + * + * Narrowed by whatever the clinician did say, so "دو" offers four and "دو بالا" offers + * two. This is not a guess — it is the full set of readings, handed to the clinician to + * choose from rather than picked on their behalf. + */ +function quadrantCandidates(intent: ToothIntent): string[] { + if (intent.kind !== 'positional') return []; + const arches = + intent.arch === 'upper' || intent.arch === 'lower' ? [intent.arch] : ARCHES; + const sides = + intent.side === 'patient_right' || intent.side === 'patient_left' + ? [intent.side] + : SIDES; + + const codes: string[] = []; + for (const arch of arches) { + for (const side of sides) { + const fdi = toFdi(arch, side, intent.position); + if (fdi) codes.push(fdi); + } + } + return codes.sort(); +} + function spokenOf(intent: ToothIntent): string { const spoken = (intent as { spoken?: unknown })?.spoken; return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : ''; @@ -101,14 +136,22 @@ export function resolveToothIntents( } const reason = unresolvedReason(intent); const spoken = spokenOf(intent); + const candidates = + reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : []; // Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost - // references would collapse into one blank review row and a tooth would vanish. + // references would collapse into one blank review row and a tooth would vanish. The + // candidates are part of the identity: the same word with a different arch heard + // offers a different choice. if (spoken) { - const key = `${spoken}::${reason}`; + const key = `${spoken}::${reason}::${candidates.join(',')}`; if (seenUnresolved.has(key)) continue; seenUnresolved.add(key); } - unresolved.push({ spoken, reason }); + unresolved.push( + candidates.length > 0 + ? { spoken, reason, candidates } + : { spoken, reason }, + ); } return { teeth: [...teeth].sort(), unresolved }; diff --git a/backend/src/modules/voice/voice.types.ts b/backend/src/modules/voice/voice.types.ts index 7d3b3f3..d200c40 100644 --- a/backend/src/modules/voice/voice.types.ts +++ b/backend/src/modules/voice/voice.types.ts @@ -77,4 +77,11 @@ export type UnresolvedItem = { /** The transcript span that could not be resolved, so the user can see what was heard. */ spoken: string; reason: UnresolvedReason; + /** + * FDI codes still consistent with what was heard, when a choice would settle it. + * Only `tooth_missing_quadrant` carries these: "دو" leaves four teeth on the table, + * "دو بالا" leaves two. The review sheet offers them so an under-specified tooth is one + * tap from resolved rather than a dead end. + */ + candidates?: string[]; };