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: [],
});
});
});

View File

@@ -0,0 +1,108 @@
import { isFdiTooth, toFdi } from '../../common/fdi';
import type { ToothIntent, UnresolvedItem } from './voice.types';
export type ToothResolution = {
/** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */
teeth: string[];
unresolved: UnresolvedItem[];
};
/** Everything here parses untrusted model output, so nothing may throw. */
function normalizedFdi(intent: ToothIntent): string {
const raw = (intent as { fdi?: unknown }).fdi;
// Trimmed for parity with normalizeTeeth — '14 ' is tooth 14 through the treatment API
// and must not be "malformed" here.
return typeof raw === 'string' ? raw.trim() : '';
}
/**
* Resolve one spoken tooth reference to an FDI code, or null.
*
* Never guesses and never clamps: a position of 9, a deciduous tooth, or a malformed
* intent resolves to null so the caller can surface it as "not understood" rather than
* silently selecting a neighbouring tooth.
*/
export function resolveToothIntent(intent: ToothIntent): string | null {
if (!intent || typeof intent !== 'object') return null;
if (intent.kind === 'explicit') {
const fdi = normalizedFdi(intent);
return isFdiTooth(fdi) ? fdi : null;
}
if (intent.kind === 'positional') {
if (intent.arch !== 'upper' && intent.arch !== 'lower') return null;
if (intent.side !== 'patient_right' && intent.side !== 'patient_left')
return null;
return toFdi(intent.arch, intent.side, intent.position);
}
return null;
}
function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
if (!intent || typeof intent !== 'object') return 'malformed';
if (intent.kind === 'explicit') {
const fdi = normalizedFdi(intent);
// Quadrants 1-4 are permanent and would already have resolved, so a well-formed
// quadrant+position reaching here is quadrant 5-8: deciduous. Anything else is noise.
return /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed';
}
if (intent.kind === 'positional') {
const positionBad =
!Number.isInteger(intent.position) ||
intent.position < 1 ||
intent.position > 8;
return positionBad ? 'position_out_of_range' : 'malformed';
}
return 'malformed';
}
function spokenOf(intent: ToothIntent): string {
const spoken = (intent as { spoken?: unknown })?.spoken;
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
}
/**
* Resolve a list of spoken tooth references.
*
* Duplicates collapse — a clinician may name the same tooth twice in one sentence — and
* anything unresolvable is reported rather than dropped, so the review sheet can show the
* user exactly which words were not understood.
*/
export function resolveToothIntents(
intents: readonly ToothIntent[],
): ToothResolution {
const teeth = new Set<string>();
const unresolved: UnresolvedItem[] = [];
const seenUnresolved = new Set<string>();
// Not `intents ?? []`: a model may return an object or a number here, and a
// non-iterable must degrade like any other malformed shape rather than throw.
const list: readonly ToothIntent[] = Array.isArray(intents)
? (intents as readonly ToothIntent[])
: [];
for (const intent of list) {
const fdi = resolveToothIntent(intent);
if (fdi) {
teeth.add(fdi);
continue;
}
const reason = unresolvedReason(intent);
const spoken = spokenOf(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.
if (spoken) {
const key = `${spoken}::${reason}`;
if (seenUnresolved.has(key)) continue;
seenUnresolved.add(key);
}
unresolved.push({ spoken, reason });
}
return { teeth: [...teeth].sort(), unresolved };
}

View File

@@ -0,0 +1,77 @@
import type { Arch, PatientSide } from '../../common/fdi';
/**
* What the extraction model is allowed to return.
*
* The model emits *intents*, never resolved values: no FDI codes, no ISO dates. Pure,
* unit-tested resolvers turn intents into domain values, so the two highest-consequence
* mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful.
*/
/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */
export type ToothIntent =
| { kind: 'explicit'; fdi: string; spoken: string }
| {
kind: 'positional';
arch: Arch;
side: PatientSide;
/** 1 = central incisor … 8 = third molar. */
position: number;
spoken: string;
};
/** A spoken deadline. The model never does calendar arithmetic. */
export type DueIntent =
| { kind: 'weekday'; weekday: Weekday; which: 'this' | 'next' }
| { kind: 'offset'; unit: 'day' | 'week' | 'month'; amount: number }
| { kind: 'jalali'; jy: number; jm: number; jd: number }
| { kind: 'gregorian'; y: number; m: number; d: number };
export const WEEKDAYS = [
'saturday',
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
] as const;
export type Weekday = (typeof WEEKDAYS)[number];
/** Two teeth defining an inclusive connected (bridge) span. */
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
export type ProsthesisIntent = {
/** Catalog code applied to every tooth unless overridden. */
defaultType: string | null;
overrides: { tooth: ToothIntent; type: string }[];
};
export type VoiceIntent = {
treatmentType: string | null;
teeth: ToothIntent[];
connectedSpans: ConnectedSpanIntent[];
comment: string | null;
prosthesis: ProsthesisIntent | null;
/** Must be one of the linked-lab ids supplied in the prompt, or null. */
labId: string | null;
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */
labMatchExact: boolean;
due: DueIntent | null;
};
/** Why a spoken item could not be turned into a domain value. Shown to the user. */
export type UnresolvedReason =
| 'not_permanent_tooth'
| 'position_out_of_range'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'invalid_date';
export type UnresolvedItem = {
/** The transcript span that could not be resolved, so the user can see what was heard. */
spoken: string;
reason: UnresolvedReason;
};