"ترمیم برای دندون دو" 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) <noreply@anthropic.com>
221 lines
7.3 KiB
TypeScript
221 lines
7.3 KiB
TypeScript
import {
|
|
resolveToothIntent,
|
|
resolveToothIntents,
|
|
} from './tooth-intent.resolver';
|
|
import type { ToothIntent } from './voice.types';
|
|
|
|
const positional = (
|
|
arch: 'upper' | 'lower',
|
|
side: 'patient_right' | 'patient_left',
|
|
position: number,
|
|
spoken = 'x',
|
|
): ToothIntent => ({ kind: 'positional', arch, side, position, spoken });
|
|
|
|
const explicit = (fdi: string, spoken = 'x'): ToothIntent => ({
|
|
kind: 'explicit',
|
|
fdi,
|
|
spoken,
|
|
});
|
|
|
|
describe('resolveToothIntent', () => {
|
|
describe('positional intents', () => {
|
|
// "شش بالا راست" — upper right six — must be 16, not 26. A mirrored quadrant is a
|
|
// valid code for the wrong tooth and reaches the lab unnoticed.
|
|
it('resolves each quadrant from the patient perspective', () => {
|
|
expect(resolveToothIntent(positional('upper', 'patient_right', 6))).toBe(
|
|
'16',
|
|
);
|
|
expect(resolveToothIntent(positional('upper', 'patient_left', 6))).toBe(
|
|
'26',
|
|
);
|
|
expect(resolveToothIntent(positional('lower', 'patient_left', 6))).toBe(
|
|
'36',
|
|
);
|
|
expect(resolveToothIntent(positional('lower', 'patient_right', 6))).toBe(
|
|
'46',
|
|
);
|
|
});
|
|
|
|
it('returns null for an out-of-range position instead of clamping', () => {
|
|
expect(
|
|
resolveToothIntent(positional('upper', 'patient_right', 9)),
|
|
).toBeNull();
|
|
expect(
|
|
resolveToothIntent(positional('upper', 'patient_right', 0)),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns null for a malformed arch or side', () => {
|
|
expect(
|
|
resolveToothIntent(
|
|
positional('sideways' as 'upper', 'patient_right', 6),
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
resolveToothIntent(
|
|
positional('upper', 'viewer_right' as 'patient_right', 6),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('explicit intents', () => {
|
|
it('accepts a permanent FDI code', () => {
|
|
expect(resolveToothIntent(explicit('14'))).toBe('14');
|
|
expect(resolveToothIntent(explicit('48'))).toBe('48');
|
|
});
|
|
|
|
it('rejects deciduous codes rather than snapping to a permanent tooth', () => {
|
|
expect(resolveToothIntent(explicit('51'))).toBeNull();
|
|
expect(resolveToothIntent(explicit('85'))).toBeNull();
|
|
});
|
|
|
|
it('rejects nonsense', () => {
|
|
for (const value of ['', '1', '99', '140']) {
|
|
expect(resolveToothIntent(explicit(value))).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
it('returns null for a missing or unknown intent shape', () => {
|
|
expect(resolveToothIntent(undefined as unknown as ToothIntent)).toBeNull();
|
|
expect(
|
|
resolveToothIntent({ kind: 'guess' } as unknown as ToothIntent),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveToothIntents', () => {
|
|
it('resolves a mixed list and sorts the result', () => {
|
|
const result = resolveToothIntents([
|
|
positional('upper', 'patient_right', 5, 'پنج بالا راست'),
|
|
explicit('14', 'یک چهار'),
|
|
]);
|
|
expect(result.teeth).toEqual(['14', '15']);
|
|
expect(result.unresolved).toEqual([]);
|
|
});
|
|
|
|
it('collapses a tooth named twice', () => {
|
|
const result = resolveToothIntents([
|
|
explicit('14', 'چهارده'),
|
|
positional('upper', 'patient_right', 4, 'چهار بالا راست'),
|
|
]);
|
|
expect(result.teeth).toEqual(['14']);
|
|
});
|
|
|
|
it('reports what it could not understand instead of dropping it', () => {
|
|
const result = resolveToothIntents([
|
|
explicit('14', 'یک چهار'),
|
|
explicit('51', 'دندان شیری'),
|
|
positional('upper', 'patient_right', 9, 'نه بالا راست'),
|
|
]);
|
|
expect(result.teeth).toEqual(['14']);
|
|
expect(result.unresolved).toEqual([
|
|
{ spoken: 'دندان شیری', reason: 'not_permanent_tooth' },
|
|
{ spoken: 'نه بالا راست', reason: 'position_out_of_range' },
|
|
]);
|
|
});
|
|
|
|
it('does not repeat an identical unresolved item', () => {
|
|
const result = resolveToothIntents([
|
|
explicit('51', 'شیری'),
|
|
explicit('51', 'شیری'),
|
|
]);
|
|
expect(result.unresolved).toHaveLength(1);
|
|
});
|
|
|
|
it('survives a non-array where the model should have sent a list', () => {
|
|
// The model can return an object or a number here; that must degrade, not 500.
|
|
for (const bad of [undefined, null, 5, 'teeth', { fdi: '14' }]) {
|
|
expect(resolveToothIntents(bad as unknown as ToothIntent[])).toEqual({
|
|
teeth: [],
|
|
unresolved: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
it('trims an explicit code, matching normalizeTeeth', () => {
|
|
expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']);
|
|
});
|
|
|
|
it('distinguishes a deciduous tooth from nonsense in the reason it reports', () => {
|
|
// '51' really is a (primary) tooth the chart cannot show; '99' is not a tooth at all.
|
|
expect(
|
|
resolveToothIntents([explicit('51', 'shiri')]).unresolved[0].reason,
|
|
).toBe('not_permanent_tooth');
|
|
for (const junk of ['99', '19', '140', '', 'ab']) {
|
|
expect(
|
|
resolveToothIntents([explicit(junk, `j-${junk}`)]).unresolved[0].reason,
|
|
).toBe('malformed');
|
|
}
|
|
});
|
|
|
|
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([
|
|
{ kind: 'explicit', fdi: '51' } as ToothIntent,
|
|
{ kind: 'explicit', fdi: '52' } as ToothIntent,
|
|
]);
|
|
expect(result.unresolved).toHaveLength(2);
|
|
});
|
|
|
|
it('handles an empty or missing list', () => {
|
|
expect(resolveToothIntents([])).toEqual({ teeth: [], unresolved: [] });
|
|
expect(resolveToothIntents(undefined as unknown as ToothIntent[])).toEqual({
|
|
teeth: [],
|
|
unresolved: [],
|
|
});
|
|
});
|
|
});
|