feat(voice): adapt voice entry to the stacked-jobs prosthesis model

Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.

Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.

Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().

Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.

Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.

KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
  type-row lock is missing, so unticking it saves prosthesis lab rows on a
  non-prosthesis detail

Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.

Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 12:23:58 +08:00
parent 77e2ed4b42
commit 15ddb9aac2
29 changed files with 3630 additions and 524 deletions

View File

@@ -1,10 +1,22 @@
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,
resolveProsthesis,
resolveProsthesisAssignment,
resolveVoiceIntent,
type ProsthesisLeaf,
type ResolveContext,
} from './extraction.resolver';
import type { ToothIntent, VoiceIntent } from './voice.types';
import type {
ProsthesisAssignment,
ToothIntent,
UnresolvedItem,
VoiceIntent,
} from './voice.types';
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
kind: 'explicit',
@@ -12,12 +24,75 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
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']),
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
prosthesisLeaves: PROSTHESIS_LEAVES,
prosthesisCategoryCodes: new Set([
'crown',
'implant',
'removable',
'appliance',
]),
prosthesisSubcategoryCodes: new Set(['night_guard']),
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
};
@@ -111,122 +186,256 @@ describe('resolveConnectedSpans', () => {
});
});
describe('resolveProsthesis', () => {
const allowed = CTX.prosthesisTypeCodes;
it('expands the default across every tooth', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
['14', '15'],
allowed,
describe('resolveProsthesisAssignment', () => {
function resolve(assignment: ProsthesisAssignment, index = 0) {
const unresolved: UnresolvedItem[] = [];
const resolved = resolveProsthesisAssignment(
assignment,
index,
CTX,
unresolved,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'15': 'monolithic_zirconia',
return { resolved, unresolved };
}
it('resolves explicit tooth targets to FDI codes', () => {
const { resolved } = resolve({
targets: [tooth('12'), tooth('13')],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام برای ۱۲ و ۱۳',
});
expect(result.prosthesis?.complete).toBe(true);
expect(resolved.targets.sort()).toEqual(['12', '13']);
expect(resolved.types).toEqual(['pfm_crown']);
});
it('applies per-tooth overrides on top of the default', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
},
['14', '26'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'26': '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(result.prosthesis?.complete).toBe(true);
});
it('marks the map incomplete when a tooth ends up untyped', () => {
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
const result = resolveProsthesis(
{
defaultType: null,
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
},
['14', '15'],
allowed,
);
expect(result.prosthesis?.complete).toBe(false);
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
});
it('rejects a catalog code the clinic does not have', () => {
const result = resolveProsthesis(
{ defaultType: 'gold_foil', overrides: [] },
['14'],
allowed,
);
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
expect(result.prosthesis).toBeNull();
expect(result.unresolved).toEqual([
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
expect(resolved.targets).toEqual(['12']);
expect(resolved.types.sort()).toEqual([
'monolithic_zirconia',
'zirconia_abutment',
]);
});
it('ignores an override for a tooth that is not selected', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
},
['14'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
expect(result.unresolved).toEqual([
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
]);
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('reports no prosthesis at all when the object carries nothing usable', () => {
// An empty-but-present map would paint a plain restoration with a fabricated
// "incomplete, cannot ship" warning.
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
expect(
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
).toBeNull();
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(Boolean),
);
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 no prosthesis when there are no teeth to type', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
[],
allowed,
);
expect(result.prosthesis).toBeNull();
});
it('distinguishes a tooth it could not understand from one that is not selected', () => {
// Different corrective actions: add the tooth, versus repeat yourself.
const result = resolveProsthesis(
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([
{
defaultType: 'monolithic_zirconia',
overrides: [
{
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
type: 'pfm_crown',
},
],
spoken: 'gold_foil',
reason: 'unknown_catalog_code',
assignmentIndex: 0,
},
['14'],
allowed,
);
expect(result.unresolved).toEqual([
{ spoken: 'نود و نه', reason: 'malformed' },
]);
});
it('returns null when no prosthesis was spoken', () => {
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
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('defers the region check for a mixed-region category (removable)', () => {
// complete_denture is 'arch', partial_denture is 'crown' — no region to check until a
// leaf is picked. Neither a tooth nor a jaw target may be rejected while it is still
// the bare category.
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' }),
);
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).not.toContainEqual(
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
);
});
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', () => {
@@ -235,7 +444,7 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('14'), tooth('15')],
connectedSpans: [],
comment: ' حساسیت به سرما ',
prosthesis: null,
prosthesis: [],
labId: null,
labMatchExact: false,
due: null,
@@ -246,7 +455,7 @@ describe('resolveVoiceIntent', () => {
expect(result.treatmentType).toBe('restoration');
expect(result.teeth).toEqual(['14', '15']);
expect(result.comment).toBe('حساسیت به سرما');
expect(result.prosthesis).toBeNull();
expect(result.prosthesisAssignments).toEqual([]);
expect(result.unresolved).toEqual([]);
});
@@ -299,27 +508,38 @@ describe('resolveVoiceIntent', () => {
expect(result.labMatchExact).toBe(false);
});
it('applies prosthesis over the span-expanded tooth set', () => {
it('forces treatmentType to prosthesis when an assignment resolves', () => {
const result = resolveVoiceIntent(
{
...base,
treatmentType: 'prosthesis',
teeth: [tooth('14')],
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
treatmentType: 'restoration',
prosthesis: [
{
targets: [tooth('12')],
types: ['pfm_crown'],
spoken: 'روکش برای ۱۲',
},
],
},
CTX,
);
// 15 was never spoken but is part of the bridge, so it must carry a type too.
expect(result.teeth).toEqual(['14', '15', '16']);
expect(result.prosthesis?.complete).toBe(true);
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
'14',
'15',
'16',
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' } },
@@ -328,7 +548,7 @@ describe('resolveVoiceIntent', () => {
expect(result.dueDate).toBe('2025-10-16');
});
it('collects unresolved items from every stage', () => {
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
const result = resolveVoiceIntent(
{
...base,
@@ -336,16 +556,34 @@ describe('resolveVoiceIntent', () => {
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', () => {