Files
dyolink/backend/src/modules/voice/extraction.resolver.spec.ts
Amin Mousavi f1a4594a0a fix(voice): validate a code's region against its target properly
Second correctness pass on e271858 found three ways an arch-only appliance
could be written as a per-tooth job, plus a gap in the previous repair.

1. The deferred region check was never completed. A mixed-region category
   resolved with no region recorded, and nothing re-validated the leaf the
   clinician then picked — "پروتز متحرک برای دندون ۱۲" wrote a complete
   denture onto tooth 12, which the manual chart cannot produce and which
   task generation would expand as denture steps. Targets now resolve BEFORE
   types, so the target kind narrows a category's candidates and an
   impossible leaf is never offered. The deferral disappears with it.

2. `some` over the stack's regions let one legal code admit every other.
   `types: ['pfm_crown','night_guard_soft']` on tooth 12 passed, because
   `crown` suited the tooth, and wrote a night guard onto that tooth. Now
   every named code must suit the target.

3. `regions.size === 1` also deferred `implant`, whose leaves span root and
   crown — both tooth regions, so not a tooth/arch category at all. An
   implant aimed at a jaw resolved as a UA target. Narrowing by target kind
   rejects it instead.

4. The e271858 type-row lock ticked the row and the payload but not the
   count, so the sheet read "Apply 1 field" while two landed. One
   `effectiveSelection` now drives the count and the payload.

Also narrows a `filter(Boolean)` in the disjointness test that left two
implicit-any errors under the full tsconfig (nest build excludes specs, so
the repo gate never saw them). Pre-existing at 15ddb9a.

Adds four resolver tests: candidate narrowing per target kind, a category
with no usable leaf, a stack where only one code suits, and a legal stack.
All three defects passed the previous 209 tests.

Gates: backend 212 tests, nest build, ESLint clean on the voice module;
frontend 37 Vitest tests, tsc --noEmit, ESLint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:42:06 +08:00

656 lines
20 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('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();
});
});