feat(backend): voice intent contract and tooth-intent resolver

The extraction model emits intents, never resolved values — no FDI codes, no
ISO dates. This adds the contract it must satisfy and the resolver that turns
spoken tooth references into FDI, so quadrant mirroring is a unit test rather
than a hope.

resolveToothIntent never guesses and never clamps: position 9, a deciduous
tooth, or a malformed shape resolve to null and are reported as unresolved with
the transcript span that produced them, so the review sheet can show the
clinician exactly which words were not understood.

Everything here parses untrusted model output, so nothing may throw:

- a non-array where a list was expected degrades like any other malformed shape
- explicit codes are trimmed, for parity with normalizeTeeth
- '51' reports as not_permanent_tooth (a real primary tooth the chart cannot
  show) while '99' reports as malformed — the clinician should not be told a
  deciduous tooth was heard when nothing tooth-shaped was
- unresolved items only dedupe when they carry a spoken span; without one,
  collapsing them would hide a lost tooth behind a single blank review row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:13:26 +03:30
parent 4b4c197c03
commit 1076c13472
3 changed files with 354 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
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('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: [],
});
});
});