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>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
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 };
|
|
}
|