Files
dyolink/backend/src/modules/voice/tooth-intent.resolver.ts

116 lines
4.2 KiB
TypeScript
Raw Normal View History

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;
if (positionBad) return 'position_out_of_range';
// The position was understood, so the words were not the problem: the speaker never
// said which quadrant. "دندون دو" names four teeth at once, and telling the
// clinician it "could not be read" would send them looking for the wrong fault.
const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower';
const sideMissing =
intent.side !== 'patient_right' && intent.side !== 'patient_left';
return archMissing || sideMissing ? 'tooth_missing_quadrant' : '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 };
}