"یه کامپلیت دنچر برای فک بالا میخوام" 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>
734 lines
22 KiB
TypeScript
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();
|
|
});
|
|
});
|