Files
dyolink/backend/src/modules/voice/extraction.wire.ts
Amin Mousavi ae33159e7d fix(voice): stop reading a dictated jaw as a broken tooth
"یه کامپلیت دنچر برای فک بالا میخوام" 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>
2026-09-11 15:49:35 +03:30

307 lines
9.3 KiB
TypeScript

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),
};
}