Files
dyolink/backend/src/modules/voice/extraction.resolver.spec.ts
Amin Mousavi 76d929de3a fix(voice): stop reading a dictated jaw as a broken tooth
"یه کامپلیت دنچر برای فک بالا میخوام" resolved the upper arch and the
complete denture correctly, but the review sheet also showed
position_out_of_range against «فک بالا» and asked which tooth was meant. No
tooth was said.

The model names the jaw in the top-level `teeth` array as well as in the
prosthesis target it belongs to. resolveVoiceIntent passed that array
straight to resolveToothIntents, extraction.wire.ts turns `position: null`
into NaN, and unresolvedReason tests positionBad first — so it reported a
range fault for a value that was never a number, before ever reaching the
quadrant branch.

resolveVoiceIntent now drops jaw-shaped entries — an arch with no usable
position — before the tooth resolver sees them. The jaw already reaches the
form through its assignment, so the duplicate carries no information worth
reporting. An entry that DOES give a position survives: arch plus position
without a side is a real tooth described without its quadrant, and must keep
offering its candidate chips.

Two invitations removed as well, both ours: the `teeth` property in
VOICE_INTENT_JSON_SCHEMA had no description at all, and no prompt rule said a
jaw must stay out of it, while TOOTH_SCHEMA — shared with
prosthesis[].targets — describes jaws as acceptable. Adds the description and
HARD RULE 6.

Four tests. The two jaw cases fail without the filter; the out-of-range and
missing-quadrant cases pass either way and exist to prove the filter does not
over-reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:56:54 +08:00

734 lines
22 KiB
TypeScript

import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi';
import {
PROSTHESIS_TYPES,
PROSTHESIS_CATEGORY_LABELS,
PROSTHESIS_SUBCATEGORY_LABELS,
} from '../../../prisma/catalog-seed-data';
import {
resolveConnectedSpans,
resolveProsthesisAssignment,
resolveVoiceIntent,
type ProsthesisLeaf,
type ResolveContext,
} from './extraction.resolver';
import type {
ProsthesisAssignment,
ToothIntent,
UnresolvedItem,
VoiceIntent,
} from './voice.types';
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
kind: 'explicit',
fdi,
spoken,
});
const positional = (
overrides: Partial<Extract<ToothIntent, { kind: 'positional' }>> = {},
): ToothIntent => ({
kind: 'positional',
arch: undefined as never,
side: undefined as never,
position: Number.NaN,
spoken: '',
...overrides,
});
const PROSTHESIS_LEAVES: ProsthesisLeaf[] = [
{
code: 'pfm_crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'monolithic_zirconia',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'zirconia_abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
},
{
code: 'screw_retained',
category: 'implant',
subcategory: '',
chartRegion: 'crown',
},
{
code: 'night_guard_soft',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
},
{
code: 'complete_denture',
category: 'removable',
subcategory: '',
chartRegion: 'arch',
},
{
code: 'partial_denture',
category: 'removable',
subcategory: '',
chartRegion: 'crown',
},
];
const CTX: ResolveContext = {
todayIso: '2025-10-11',
weekStartJs: 6, // Saturday — the fa week
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
prosthesisLeaves: PROSTHESIS_LEAVES,
prosthesisCategoryCodes: new Set([
'crown',
'implant',
'removable',
'appliance',
]),
prosthesisSubcategoryCodes: new Set(['night_guard']),
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
};
describe('resolveConnectedSpans', () => {
it('selects the teeth between the endpoints, which were never named', () => {
// "a bridge from 14 to 16" must select 15 too.
const result = resolveConnectedSpans(
[{ from: tooth('14'), to: tooth('16') }],
[],
);
expect(result.teeth).toEqual(['14', '15', '16']);
expect(result.groups).toEqual([
{ groupId: 'voice-c1', kind: 'connected', teeth: ['16', '15', '14'] },
]);
});
it('orders group teeth along the arch, not lexically', () => {
const result = resolveConnectedSpans(
[{ from: tooth('16'), to: tooth('14') }],
[],
);
expect(result.groups[0].teeth).toEqual(['16', '15', '14']);
});
it('spans the midline', () => {
const result = resolveConnectedSpans(
[{ from: tooth('12'), to: tooth('22') }],
[],
);
expect(result.groups[0].teeth).toEqual(['12', '11', '21', '22']);
});
it('merges overlapping spans into one bridge', () => {
const result = resolveConnectedSpans(
[
{ from: tooth('14'), to: tooth('16') },
{ from: tooth('15'), to: tooth('17') },
],
[],
);
const connected = result.groups.filter((g) => g.kind === 'connected');
expect(connected).toHaveLength(1);
expect(connected[0].teeth).toEqual(['17', '16', '15', '14']);
});
it('gives loose teeth their own single groups', () => {
const result = resolveConnectedSpans(
[{ from: tooth('14'), to: tooth('15') }],
['26'],
);
expect(result.groups).toEqual([
{ groupId: 'voice-c1', kind: 'connected', teeth: ['15', '14'] },
{ groupId: 'voice-s-26', kind: 'single', teeth: ['26'] },
]);
});
it('never produces a one-tooth connected group', () => {
const result = resolveConnectedSpans(
[{ from: tooth('14'), to: tooth('14') }],
[],
);
expect(result.groups).toEqual([
{ groupId: 'voice-s-14', kind: 'single', teeth: ['14'] },
]);
expect(result.unresolved).toEqual([]);
});
it('reports a cross-arch span rather than guessing', () => {
const result = resolveConnectedSpans(
[{ from: tooth('14', 'چهارده'), to: tooth('44', 'چهل و چهار') }],
[],
);
expect(result.groups).toEqual([]);
expect(result.unresolved).toEqual([
{ spoken: 'چهارده → چهل و چهار', reason: 'span_not_same_arch' },
]);
});
it('reports a span with an unresolvable endpoint', () => {
const result = resolveConnectedSpans(
[{ from: tooth('14'), to: tooth('99') }],
[],
);
expect(result.unresolved[0].reason).toBe('malformed');
});
it('survives a non-array', () => {
expect(resolveConnectedSpans(undefined as never, ['14']).teeth).toEqual([
'14',
]);
});
});
describe('resolveProsthesisAssignment', () => {
function resolve(assignment: ProsthesisAssignment, index = 0) {
const unresolved: UnresolvedItem[] = [];
const resolved = resolveProsthesisAssignment(
assignment,
index,
CTX,
unresolved,
);
return { resolved, unresolved };
}
it('resolves explicit tooth targets to FDI codes', () => {
const { resolved } = resolve({
targets: [tooth('12'), tooth('13')],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام برای ۱۲ و ۱۳',
});
expect(resolved.targets.sort()).toEqual(['12', '13']);
expect(resolved.types).toEqual(['pfm_crown']);
});
it('resolves a stack — more than one type on the same target', () => {
const { resolved } = resolve({
targets: [tooth('12')],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
});
expect(resolved.targets).toEqual(['12']);
expect(resolved.types.sort()).toEqual([
'monolithic_zirconia',
'zirconia_abutment',
]);
});
it('resolves an arch target with no position to the jaw sentinel', () => {
const { resolved } = resolve({
targets: [positional({ arch: 'upper' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد فک بالا',
});
expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
});
it('resolves "both jaws" to both sentinels', () => {
const { resolved } = resolve({
targets: [positional({ arch: 'both' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد هر دو فک',
});
expect(resolved.targets.sort()).toEqual(
[ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER].sort(),
);
});
it('classifies a leaf, a category and a subcategory code independently', () => {
const leaf = resolve({
targets: [tooth('12')],
types: ['pfm_crown'],
spoken: '',
});
expect(leaf.resolved.types).toEqual(['pfm_crown']);
expect(leaf.unresolved).toEqual([]);
const category = resolve({
targets: [tooth('12')],
types: ['crown'],
spoken: 'روکش',
});
expect(category.resolved.types).toEqual([]);
expect(category.unresolved).toEqual([
{
spoken: 'روکش',
reason: 'prosthesis_type_ambiguous',
candidates: ['monolithic_zirconia', 'pfm_crown'],
assignmentIndex: 0,
},
]);
const subcategory = resolve({
targets: [positional({ arch: 'upper' })],
types: ['night_guard'],
spoken: 'نایت گارد',
});
expect(subcategory.resolved.types).toEqual([]);
expect(subcategory.unresolved[0]).toMatchObject({
reason: 'prosthesis_type_ambiguous',
candidates: ['night_guard_soft'],
});
});
it('asserts the leaf, category and subcategory namespaces are disjoint on the live catalog', () => {
const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code));
const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category));
const subcategoryCodes = new Set(
PROSTHESIS_TYPES.map((t) => t.subcategory).filter(
(code): code is string => Boolean(code),
),
);
for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false);
for (const code of subcategoryCodes) {
expect(leafCodes.has(code)).toBe(false);
expect(categoryCodes.has(code)).toBe(false);
}
});
it('reports a code not in the supplied catalog', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['gold_foil'],
spoken: '',
});
expect(resolved.types).toEqual([]);
expect(unresolved).toEqual([
{
spoken: 'gold_foil',
reason: 'unknown_catalog_code',
assignmentIndex: 0,
},
]);
});
it('rejects an arch code aimed at a tooth', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['night_guard_soft'],
spoken: 'دندون ۱۲ نایت گارد',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toEqual([
{ spoken: '12', reason: 'code_not_valid_for_target', assignmentIndex: 0 },
]);
});
it('rejects a tooth code aimed at a jaw', () => {
const { resolved, unresolved } = resolve({
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['pfm_crown'],
spoken: 'فک بالا',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toEqual([
{
spoken: 'فک بالا',
reason: 'code_not_valid_for_target',
assignmentIndex: 0,
},
]);
});
it('narrows a mixed-region category to the leaves the target can carry', () => {
// complete_denture is 'arch', partial_denture is 'crown'. Deferring the check until a
// leaf was picked left nothing to complete it, and wrote a complete denture onto one
// tooth. The target kind narrows the candidates instead, so an impossible leaf is never
// offered.
const toothTarget = resolve({
targets: [tooth('12')],
types: ['removable'],
spoken: 'دنچر برای ۱۲',
});
expect(toothTarget.resolved.targets).toEqual(['12']);
expect(toothTarget.unresolved).toContainEqual(
expect.objectContaining({
reason: 'prosthesis_type_ambiguous',
candidates: ['partial_denture'],
}),
);
expect(toothTarget.unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
const jawTarget = resolve({
targets: [positional({ arch: 'upper' })],
types: ['removable'],
spoken: 'دنچر فک بالا',
});
expect(jawTarget.resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
expect(jawTarget.unresolved).toContainEqual(
expect.objectContaining({
reason: 'prosthesis_type_ambiguous',
candidates: ['complete_denture'],
}),
);
expect(jawTarget.unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
});
it('rejects a category with no leaf the target can carry', () => {
// `implant` spans root + crown, both tooth regions, so it is not a mixed tooth/arch
// category and must not defer. Aimed at a jaw it is a contradiction.
const { resolved, unresolved } = resolve({
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['implant'],
spoken: 'ایمپلنت بالا',
});
expect(resolved.targets).toEqual([]);
expect(resolved.types).toEqual([]);
expect(unresolved).toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
expect(unresolved).not.toContainEqual(
expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }),
);
});
it('rejects a stack where only one code suits the target', () => {
// One legal crown must not admit an arch-only appliance onto the same tooth. Validating
// with `some` over the stack's regions did exactly that.
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['pfm_crown', 'night_guard_soft'],
spoken: 'دندون ۱۲ روکش و نایت گارد',
});
expect(resolved.targets).toEqual([]);
expect(unresolved).toContainEqual(
expect.objectContaining({
spoken: '12',
reason: 'code_not_valid_for_target',
}),
);
});
it('accepts a stack whose every code suits the target', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'دندون ۱۲ ایمپلنت با روکش زیرکونیا',
});
expect(resolved.targets).toEqual(['12']);
expect(resolved.types).toEqual([
'zirconia_abutment',
'monolithic_zirconia',
]);
expect(unresolved).toEqual([]);
});
it('a target with no types resolves with an empty types[], and does not fail the assignment', () => {
const { resolved } = resolve({
targets: [tooth('13'), tooth('14')],
types: [],
spoken: '۱۳ و ۱۴',
});
// Nothing to validate a region against yet, so a bare target still resolves as valid
// geometry — the frontend renders it struck through ("no prosthesis heard") because
// `types` is empty and no `prosthesis_type_ambiguous` item references this assignment.
expect(resolved.targets.sort()).toEqual(['13', '14']);
expect(resolved.types).toEqual([]);
});
it('one target failing validity does not exclude the rest of the assignment', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12'), positional({ arch: 'upper' })],
types: ['pfm_crown'],
spoken: '',
});
expect(resolved.targets).toEqual(['12']);
expect(unresolved).toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
});
it('carries an assignment target that could not be resolved, with the arch candidates', () => {
const { unresolved } = resolve({
targets: [positional({ spoken: 'نایت گارد' })],
types: ['night_guard_soft'],
spoken: 'نایت گارد',
});
expect(unresolved).toContainEqual({
spoken: 'نایت گارد',
reason: 'arch_not_spoken',
candidates: ['upper', 'lower'],
assignmentIndex: 0,
});
});
it('carries the assignment index on a missing-quadrant target', () => {
const { unresolved } = resolve({
targets: [positional({ position: 2, spoken: 'دندون دو' })],
types: ['pfm_crown'],
spoken: 'دندون دو روکش',
});
expect(unresolved).toContainEqual(
expect.objectContaining({
reason: 'tooth_missing_quadrant',
assignmentIndex: 0,
}),
);
});
});
describe('every prosthesis category and subcategory has a non-empty label in fa/en/nl', () => {
const locales = ['fa', 'en', 'nl'] as const;
it.each(Object.entries(PROSTHESIS_CATEGORY_LABELS))(
'category %s',
(_code, labels) => {
for (const locale of locales) {
expect(labels[locale]?.trim()).toBeTruthy();
}
},
);
it.each(Object.entries(PROSTHESIS_SUBCATEGORY_LABELS))(
'subcategory %s',
(_code, labels) => {
for (const locale of locales) {
expect(labels[locale]?.trim()).toBeTruthy();
}
},
);
});
describe('resolveVoiceIntent', () => {
const base: VoiceIntent = {
treatmentType: 'restoration',
teeth: [tooth('14'), tooth('15')],
connectedSpans: [],
comment: ' حساسیت به سرما ',
prosthesis: [],
labId: null,
labMatchExact: false,
due: null,
};
it('composes a plain restoration', () => {
const result = resolveVoiceIntent(base, CTX);
expect(result.treatmentType).toBe('restoration');
expect(result.teeth).toEqual(['14', '15']);
expect(result.comment).toBe('حساسیت به سرما');
expect(result.prosthesisAssignments).toEqual([]);
expect(result.unresolved).toEqual([]);
});
it('rejects a treatment type outside the catalog', () => {
const result = resolveVoiceIntent(
{ ...base, treatmentType: 'teeth_whitening' },
CTX,
);
expect(result.treatmentType).toBeNull();
expect(result.unresolved).toContainEqual({
spoken: 'teeth_whitening',
reason: 'unknown_catalog_code',
});
});
it('drops a lab id the clinic is not linked to', () => {
// Shipping to a lab the clinic never named is worse than shipping nowhere.
const result = resolveVoiceIntent(
{ ...base, labId: 'lab-elsewhere', labMatchExact: true },
CTX,
);
expect(result.labId).toBeNull();
expect(result.labMatchExact).toBe(false);
});
it('keeps a linked lab and its exactness flag', () => {
const result = resolveVoiceIntent(
{ ...base, labId: 'lab-sina', labMatchExact: true },
CTX,
);
expect(result.labId).toBe('lab-sina');
expect(result.labMatchExact).toBe(true);
});
it('reports a hallucinated lab rather than dropping it silently', () => {
// A near-miss lab id must not look identical to "no lab was spoken".
const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX);
// The id is not what the clinician said — quoting it back shows them a raw UUID.
expect(result.unresolved).toContainEqual({
spoken: '',
reason: 'unknown_catalog_code',
});
});
it('never reports an inexact match as exact when the lab was dropped', () => {
const result = resolveVoiceIntent(
{ ...base, labId: null, labMatchExact: true },
CTX,
);
expect(result.labMatchExact).toBe(false);
});
it('forces treatmentType to prosthesis when an assignment resolves', () => {
const result = resolveVoiceIntent(
{
...base,
treatmentType: 'restoration',
prosthesis: [
{
targets: [tooth('12')],
types: ['pfm_crown'],
spoken: 'روکش برای ۱۲',
},
],
},
CTX,
);
expect(result.treatmentType).toBe('prosthesis');
expect(result.prosthesisAssignments).toEqual([
{ targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' },
]);
});
it('does not force prosthesis when every assignment resolved no target', () => {
const result = resolveVoiceIntent(
{
...base,
prosthesis: [{ targets: [], types: ['pfm_crown'], spoken: '' }],
},
CTX,
);
expect(result.treatmentType).toBe('restoration');
});
it('does not treat a spoken jaw as a broken tooth', () => {
// "یه کامپلیت دنچر برای فک بالا" — the model names the jaw in `teeth` as well as in the
// assignment target. The arch reached the form correctly, but the duplicate reported
// `position_out_of_range`, so the sheet asked which tooth was meant. No tooth was said.
const result = resolveVoiceIntent(
{
...base,
treatmentType: null,
teeth: [positional({ arch: 'upper', spoken: 'فک بالا' })],
prosthesis: [
{
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
types: ['complete_denture'],
spoken: 'یه کامپلیت دنچر برای فک بالا',
},
],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([]);
expect(result.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
expect(result.treatmentType).toBe('prosthesis');
});
it('drops a both-jaws reference from the teeth list too', () => {
const result = resolveVoiceIntent(
{
...base,
teeth: [positional({ arch: 'both', spoken: 'هر دو فک' })],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([]);
});
it('still reports a tooth whose position is out of range', () => {
// The jaw filter must not swallow a real fault: a position was given, and it is wrong.
const result = resolveVoiceIntent(
{
...base,
teeth: [
positional({
arch: 'upper',
side: 'patient_right',
position: 9,
spoken: 'دندون نه',
}),
],
},
CTX,
);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toContainEqual({
spoken: 'دندون نه',
reason: 'position_out_of_range',
});
});
it('still offers candidates for a tooth described without its quadrant', () => {
// arch + position, no side: a real tooth, under-specified. Must survive the filter.
const result = resolveVoiceIntent(
{
...base,
teeth: [positional({ arch: 'upper', position: 2, spoken: 'دو بالا' })],
},
CTX,
);
expect(result.unresolved).toContainEqual(
expect.objectContaining({
spoken: 'دو بالا',
reason: 'tooth_missing_quadrant',
candidates: ['12', '22'],
}),
);
});
it('resolves a due date through the same context', () => {
const result = resolveVoiceIntent(
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
CTX,
);
expect(result.dueDate).toBe('2025-10-16');
});
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
const result = resolveVoiceIntent(
{
...base,
treatmentType: 'nope',
teeth: [tooth('51', 'شیری')],
connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
prosthesis: [
{
targets: [tooth('99', 'نود و نه')],
types: ['pfm_crown'],
spoken: '',
},
],
},
CTX,
);
const reasons = result.unresolved.map((u) => u.reason).sort();
expect(reasons).toEqual([
'invalid_date',
'malformed',
'not_permanent_tooth',
'span_not_same_arch',
'unknown_catalog_code',
]);
const outsideAssignment = result.unresolved.filter(
(u) => u.reason === 'not_permanent_tooth',
);
expect(outsideAssignment[0].assignmentIndex).toBeUndefined();
const insideAssignment = result.unresolved.filter(
(u) => u.reason === 'malformed',
);
expect(insideAssignment[0].assignmentIndex).toBe(0);
});
it('treats an empty comment as absent', () => {
expect(
resolveVoiceIntent({ ...base, comment: ' ' }, CTX).comment,
).toBeNull();
});
});