feat(backend): offer the candidate teeth for an unspecified quadrant

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 04:44:20 +08:00
parent 52a7359b9a
commit 82fad4ac02
3 changed files with 92 additions and 5 deletions

View File

@@ -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 };