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:
@@ -2,7 +2,7 @@ import { normalizeFdiCode } from '../../common/fdi';
|
||||
import type {
|
||||
ConnectedSpanIntent,
|
||||
DueIntent,
|
||||
ProsthesisIntent,
|
||||
ProsthesisAssignment,
|
||||
ToothIntent,
|
||||
VoiceIntent,
|
||||
Weekday,
|
||||
@@ -20,7 +20,7 @@ export type WireToothIntent = {
|
||||
spoken: string;
|
||||
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */
|
||||
fdi: string | null;
|
||||
arch: 'upper' | 'lower' | null;
|
||||
arch: 'upper' | 'lower' | 'both' | null;
|
||||
side: 'patient_right' | 'patient_left' | null;
|
||||
position: number | null;
|
||||
};
|
||||
@@ -39,13 +39,18 @@ export type WireDue = {
|
||||
d: number | null;
|
||||
};
|
||||
|
||||
export type WireProsthesisAssignment = {
|
||||
targets: WireToothIntent[];
|
||||
types: string[];
|
||||
spoken: string;
|
||||
};
|
||||
|
||||
export type WireVoiceIntent = {
|
||||
treatmentType: string | null;
|
||||
teeth: WireToothIntent[];
|
||||
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
|
||||
comment: string | null;
|
||||
prosthesisDefaultType: string | null;
|
||||
prosthesisOverrides: { tooth: WireToothIntent; type: string }[];
|
||||
prosthesis: WireProsthesisAssignment[];
|
||||
labId: string | null;
|
||||
labMatchExact: boolean;
|
||||
due: WireDue;
|
||||
@@ -58,15 +63,21 @@ const TOOTH_SCHEMA = {
|
||||
properties: {
|
||||
spoken: {
|
||||
type: 'string',
|
||||
description: 'The exact transcript words for this tooth.',
|
||||
description: 'The exact transcript words for this tooth (or jaw).',
|
||||
},
|
||||
fdi: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
|
||||
'when the tooth was described in words instead of numbered.',
|
||||
'when the tooth was described in words instead of numbered, or the target is a jaw.',
|
||||
},
|
||||
arch: {
|
||||
type: ['string', 'null'],
|
||||
enum: ['upper', 'lower', 'both', null],
|
||||
description:
|
||||
'"both" is only ever used for a jaw-level prosthesis target (e.g. an appliance for ' +
|
||||
'both jaws), never for a single tooth.',
|
||||
},
|
||||
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
|
||||
side: {
|
||||
type: ['string', 'null'],
|
||||
enum: ['patient_right', 'patient_left', null],
|
||||
@@ -75,7 +86,33 @@ const TOOTH_SCHEMA = {
|
||||
position: {
|
||||
type: ['integer', 'null'],
|
||||
description:
|
||||
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
|
||||
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI ' +
|
||||
'code. Null when this target is a jaw rather than a tooth.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROSTHESIS_ASSIGNMENT_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['targets', 'types', 'spoken'],
|
||||
properties: {
|
||||
targets: {
|
||||
type: 'array',
|
||||
description: 'The teeth or jaws this instruction applies to.',
|
||||
items: TOOTH_SCHEMA,
|
||||
},
|
||||
types: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Prosthesis type codes to apply to every target above — a stack, e.g. an abutment ' +
|
||||
'plus a crown on the same tooth. A code from the CATEGORY or SUBCATEGORY lists is ' +
|
||||
'fine when only the general term was said.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
spoken: {
|
||||
type: 'string',
|
||||
description: 'The exact transcript words for this instruction.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -88,8 +125,7 @@ export const VOICE_INTENT_JSON_SCHEMA = {
|
||||
'teeth',
|
||||
'connectedSpans',
|
||||
'comment',
|
||||
'prosthesisDefaultType',
|
||||
'prosthesisOverrides',
|
||||
'prosthesis',
|
||||
'labId',
|
||||
'labMatchExact',
|
||||
'due',
|
||||
@@ -114,19 +150,12 @@ export const VOICE_INTENT_JSON_SCHEMA = {
|
||||
type: ['string', 'null'],
|
||||
description: 'Clinical notes, in the spoken language.',
|
||||
},
|
||||
prosthesisDefaultType: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'A prosthesis type CODE applied to every tooth unless overridden.',
|
||||
},
|
||||
prosthesisOverrides: {
|
||||
prosthesis: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['tooth', 'type'],
|
||||
properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } },
|
||||
},
|
||||
description:
|
||||
'One entry per spoken instruction: these targets get these jobs. No default and no ' +
|
||||
'overrides — every entry names its own targets.',
|
||||
items: PROSTHESIS_ASSIGNMENT_SCHEMA,
|
||||
},
|
||||
labId: {
|
||||
type: ['string', 'null'],
|
||||
@@ -190,7 +219,7 @@ function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
|
||||
}
|
||||
return {
|
||||
kind: 'positional',
|
||||
arch: wire?.arch as 'upper' | 'lower',
|
||||
arch: wire?.arch as 'upper' | 'lower' | 'both',
|
||||
side: wire?.side as 'patient_right' | 'patient_left',
|
||||
position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
|
||||
spoken,
|
||||
@@ -236,36 +265,34 @@ function toDueIntent(wire: WireDue | undefined | null): DueIntent | null {
|
||||
}
|
||||
}
|
||||
|
||||
function toProsthesisAssignment(
|
||||
wire: WireProsthesisAssignment | undefined | null,
|
||||
): ProsthesisAssignment {
|
||||
const targets = Array.isArray(wire?.targets) ? wire.targets : [];
|
||||
const types = Array.isArray(wire?.types) ? wire.types : [];
|
||||
return {
|
||||
targets: targets.map(toToothIntent),
|
||||
types: types.filter((code): code is string => typeof code === 'string'),
|
||||
spoken: typeof wire?.spoken === 'string' ? wire.spoken : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
|
||||
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
|
||||
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
|
||||
const overrides = Array.isArray(wire?.prosthesisOverrides)
|
||||
? wire.prosthesisOverrides
|
||||
: [];
|
||||
const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : [];
|
||||
|
||||
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
|
||||
from: toToothIntent(span?.from),
|
||||
to: toToothIntent(span?.to),
|
||||
}));
|
||||
|
||||
const hasProsthesis =
|
||||
wire?.prosthesisDefaultType != null || overrides.length > 0;
|
||||
const prosthesis: ProsthesisIntent | null = hasProsthesis
|
||||
? {
|
||||
defaultType: wire?.prosthesisDefaultType ?? null,
|
||||
overrides: overrides.map((o) => ({
|
||||
tooth: toToothIntent(o?.tooth),
|
||||
type: o?.type,
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
treatmentType: wire?.treatmentType ?? null,
|
||||
teeth: teeth.map(toToothIntent),
|
||||
connectedSpans,
|
||||
comment: wire?.comment ?? null,
|
||||
prosthesis,
|
||||
prosthesis: assignments.map(toProsthesisAssignment),
|
||||
labId: wire?.labId ?? null,
|
||||
labMatchExact: wire?.labMatchExact === true,
|
||||
due: toDueIntent(wire?.due),
|
||||
|
||||
Reference in New Issue
Block a user