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:
@@ -173,10 +173,47 @@ describe('resolveToothIntents', () => {
|
|||||||
const result = resolveToothIntents([bare]);
|
const result = resolveToothIntents([bare]);
|
||||||
expect(result.teeth).toEqual([]);
|
expect(result.teeth).toEqual([]);
|
||||||
expect(result.unresolved).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', () => {
|
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.
|
// "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess.
|
||||||
for (const half of [
|
for (const half of [
|
||||||
|
|||||||
@@ -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';
|
import type { ToothIntent, UnresolvedItem } from './voice.types';
|
||||||
|
|
||||||
|
const ARCHES: readonly Arch[] = ['upper', 'lower'];
|
||||||
|
const SIDES: readonly PatientSide[] = ['patient_right', 'patient_left'];
|
||||||
|
|
||||||
export type ToothResolution = {
|
export type ToothResolution = {
|
||||||
/** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */
|
/** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
@@ -68,6 +77,32 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
|
|||||||
return 'malformed';
|
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 {
|
function spokenOf(intent: ToothIntent): string {
|
||||||
const spoken = (intent as { spoken?: unknown })?.spoken;
|
const spoken = (intent as { spoken?: unknown })?.spoken;
|
||||||
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
|
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
|
||||||
@@ -101,14 +136,22 @@ export function resolveToothIntents(
|
|||||||
}
|
}
|
||||||
const reason = unresolvedReason(intent);
|
const reason = unresolvedReason(intent);
|
||||||
const spoken = spokenOf(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
|
// 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) {
|
if (spoken) {
|
||||||
const key = `${spoken}::${reason}`;
|
const key = `${spoken}::${reason}::${candidates.join(',')}`;
|
||||||
if (seenUnresolved.has(key)) continue;
|
if (seenUnresolved.has(key)) continue;
|
||||||
seenUnresolved.add(key);
|
seenUnresolved.add(key);
|
||||||
}
|
}
|
||||||
unresolved.push({ spoken, reason });
|
unresolved.push(
|
||||||
|
candidates.length > 0
|
||||||
|
? { spoken, reason, candidates }
|
||||||
|
: { spoken, reason },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { teeth: [...teeth].sort(), unresolved };
|
return { teeth: [...teeth].sort(), unresolved };
|
||||||
|
|||||||
@@ -77,4 +77,11 @@ export type UnresolvedItem = {
|
|||||||
/** The transcript span that could not be resolved, so the user can see what was heard. */
|
/** The transcript span that could not be resolved, so the user can see what was heard. */
|
||||||
spoken: string;
|
spoken: string;
|
||||||
reason: UnresolvedReason;
|
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[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user