"ترمیم برای دندون دو" 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>
116 lines
4.2 KiB
TypeScript
116 lines
4.2 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;
|
|
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 };
|
|
}
|