import { normalizeFdiCode } from '../../common/fdi'; import type { ConnectedSpanIntent, DueIntent, ProsthesisAssignment, ToothIntent, VoiceIntent, Weekday, } from './voice.types'; import { WEEKDAYS } from './voice.types'; /** * Deliberately flat: strict `json_schema` mode has poor support for discriminated unions, * so every variant field is present and nullable. `toVoiceIntent` narrows it into the * internal union and is total — anything it cannot classify becomes a shape the resolvers * report as unresolved rather than something that throws here. */ 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' | 'both' | null; side: 'patient_right' | 'patient_left' | null; position: number | null; }; export type WireDue = { kind: 'weekday' | 'offset' | 'jalali' | 'gregorian' | 'none'; weekday: Weekday | null; which: 'this' | 'next' | null; unit: 'day' | 'week' | 'month' | null; amount: number | null; jy: number | null; jm: number | null; jd: number | null; y: number | null; m: number | null; 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; prosthesis: WireProsthesisAssignment[]; labId: string | null; labMatchExact: boolean; due: WireDue; }; const TOOTH_SCHEMA = { type: 'object', additionalProperties: false, required: ['spoken', 'fdi', 'arch', 'side', 'position'], properties: { spoken: { type: 'string', 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, 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.', }, side: { type: ['string', 'null'], enum: ['patient_right', 'patient_left', null], description: "The PATIENT's side, never the viewer's.", }, position: { type: ['integer', 'null'], description: '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; export const VOICE_INTENT_JSON_SCHEMA = { type: 'object', additionalProperties: false, required: [ 'treatmentType', 'teeth', 'connectedSpans', 'comment', 'prosthesis', 'labId', 'labMatchExact', 'due', ], properties: { treatmentType: { type: ['string', 'null'], description: 'A treatment type CODE from the supplied list, or null.', }, teeth: { type: 'array', description: 'Individual teeth only. A whole jaw NEVER belongs here — put it in ' + 'prosthesis[].targets with "arch" set and "position" null.', items: TOOTH_SCHEMA, }, connectedSpans: { type: 'array', description: 'Bridges / splinted units. Endpoints inclusive.', items: { type: 'object', additionalProperties: false, required: ['from', 'to'], properties: { from: TOOTH_SCHEMA, to: TOOTH_SCHEMA }, }, }, comment: { type: ['string', 'null'], description: 'Clinical notes, in the spoken language.', }, prosthesis: { type: 'array', 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'], description: 'An id from the supplied lab list. Never invent one.', }, labMatchExact: { type: 'boolean', description: 'True only when the spoken name matched a lab name exactly.', }, due: { type: 'object', additionalProperties: false, required: [ 'kind', 'weekday', 'which', 'unit', 'amount', 'jy', 'jm', 'jd', 'y', 'm', 'd', ], properties: { kind: { type: 'string', enum: ['weekday', 'offset', 'jalali', 'gregorian', 'none'], }, weekday: { type: ['string', 'null'], enum: [...WEEKDAYS, null] }, which: { type: ['string', 'null'], enum: ['this', 'next', null] }, unit: { type: ['string', 'null'], enum: ['day', 'week', 'month', null], }, amount: { type: ['integer', 'null'] }, jy: { type: ['integer', 'null'] }, jm: { type: ['integer', 'null'] }, jd: { type: ['integer', 'null'] }, y: { type: ['integer', 'null'] }, m: { type: ['integer', 'null'] }, d: { type: ['integer', 'null'] }, }, }, }, } as const; /** Two digits, quadrant 1-8, position 1-8 — the only thing that can be an FDI code. */ const FDI_SHAPE = /^[1-8][1-8]$/; function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent { const spoken = typeof wire?.spoken === 'string' ? wire.spoken : ''; // "۲۶" and "2 6" are FDI codes that do not match literally; unnormalised they fall // through to the positional branch with no quadrant and read as unresolved. const fdi = normalizeFdiCode(wire?.fdi); // Only take the explicit branch for something actually FDI-shaped. A model that emits // fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely. if (FDI_SHAPE.test(fdi)) { return { kind: 'explicit', fdi, spoken }; } return { kind: 'positional', 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, }; } function toDueIntent(wire: WireDue | undefined | null): DueIntent | null { switch (wire?.kind) { case 'weekday': return { kind: 'weekday', weekday: wire.weekday as Weekday, which: wire.which as 'this', }; case 'offset': return { kind: 'offset', unit: wire.unit as 'day', amount: typeof wire.amount === 'number' ? wire.amount : Number.NaN, }; case 'jalali': return { kind: 'jalali', jy: wire.jy as number, jm: wire.jm as number, jd: wire.jd as number, }; case 'gregorian': return { kind: 'gregorian', y: wire.y as number, m: wire.m as number, d: wire.d as number, }; case 'none': case undefined: return null; default: // An unrecognised kind means a deadline WAS spoken and we failed to classify it. // Passing it through lets the resolver flag it; collapsing it to null would make a // misunderstood deadline indistinguishable from no deadline at all. return { kind: wire?.kind } as unknown as DueIntent; } } 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 assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : []; const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({ from: toToothIntent(span?.from), to: toToothIntent(span?.to), })); return { treatmentType: wire?.treatmentType ?? null, teeth: teeth.map(toToothIntent), connectedSpans, comment: wire?.comment ?? null, prosthesis: assignments.map(toProsthesisAssignment), labId: wire?.labId ?? null, labMatchExact: wire?.labMatchExact === true, due: toDueIntent(wire?.due), }; }