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>
This commit is contained in:
@@ -108,6 +108,8 @@ export function buildExtractionPrompt(
|
|||||||
' "spoken", so the clinician can see what was heard.',
|
' "spoken", so the clinician can see what was heard.',
|
||||||
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
|
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
|
||||||
' one is not.',
|
' one is not.',
|
||||||
|
'6. A whole jaw is not a tooth. Never put a jaw in the "teeth" list. A jaw appears only',
|
||||||
|
' as a prosthesis[].targets entry with "arch" set and "position" null.',
|
||||||
'',
|
'',
|
||||||
'TOOTH NUMBERS',
|
'TOOTH NUMBERS',
|
||||||
'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as',
|
'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as',
|
||||||
|
|||||||
@@ -601,6 +601,84 @@ describe('resolveVoiceIntent', () => {
|
|||||||
expect(result.treatmentType).toBe('restoration');
|
expect(result.treatmentType).toBe('restoration');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat a spoken jaw as a broken tooth', () => {
|
||||||
|
// "یه کامپلیت دنچر برای فک بالا" — the model names the jaw in `teeth` as well as in the
|
||||||
|
// assignment target. The arch reached the form correctly, but the duplicate reported
|
||||||
|
// `position_out_of_range`, so the sheet asked which tooth was meant. No tooth was said.
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
treatmentType: null,
|
||||||
|
teeth: [positional({ arch: 'upper', spoken: 'فک بالا' })],
|
||||||
|
prosthesis: [
|
||||||
|
{
|
||||||
|
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
|
||||||
|
types: ['complete_denture'],
|
||||||
|
spoken: 'یه کامپلیت دنچر برای فک بالا',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.teeth).toEqual([]);
|
||||||
|
expect(result.unresolved).toEqual([]);
|
||||||
|
expect(result.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
|
||||||
|
expect(result.treatmentType).toBe('prosthesis');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a both-jaws reference from the teeth list too', () => {
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
teeth: [positional({ arch: 'both', spoken: 'هر دو فک' })],
|
||||||
|
},
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.teeth).toEqual([]);
|
||||||
|
expect(result.unresolved).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reports a tooth whose position is out of range', () => {
|
||||||
|
// The jaw filter must not swallow a real fault: a position was given, and it is wrong.
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
teeth: [
|
||||||
|
positional({
|
||||||
|
arch: 'upper',
|
||||||
|
side: 'patient_right',
|
||||||
|
position: 9,
|
||||||
|
spoken: 'دندون نه',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.teeth).toEqual([]);
|
||||||
|
expect(result.unresolved).toContainEqual({
|
||||||
|
spoken: 'دندون نه',
|
||||||
|
reason: 'position_out_of_range',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still offers candidates for a tooth described without its quadrant', () => {
|
||||||
|
// arch + position, no side: a real tooth, under-specified. Must survive the filter.
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
teeth: [positional({ arch: 'upper', position: 2, spoken: 'دو بالا' })],
|
||||||
|
},
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.unresolved).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
spoken: 'دو بالا',
|
||||||
|
reason: 'tooth_missing_quadrant',
|
||||||
|
candidates: ['12', '22'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('resolves a due date through the same context', () => {
|
it('resolves a due date through the same context', () => {
|
||||||
const result = resolveVoiceIntent(
|
const result = resolveVoiceIntent(
|
||||||
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
|
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
|
||||||
|
|||||||
@@ -481,6 +481,25 @@ export function resolveProsthesisAssignment(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole jaw is not a tooth, and the model often names one in `teeth` as well as in the
|
||||||
|
* prosthesis target it belongs to. An arch with no position then reaches `unresolvedReason`,
|
||||||
|
* which reports `position_out_of_range` — a range fault for a value that was never a number —
|
||||||
|
* and the sheet asks the clinician to repair a tooth nobody said. The jaw already reaches the
|
||||||
|
* form through its assignment, so drop the duplicate instead.
|
||||||
|
*
|
||||||
|
* A position that IS given stays: arch + position without a side is a real tooth described
|
||||||
|
* without its quadrant, and must keep offering its candidate chips.
|
||||||
|
*/
|
||||||
|
function isJawReference(intent: ToothIntent): boolean {
|
||||||
|
if (intent?.kind !== 'positional') return false;
|
||||||
|
const archGiven =
|
||||||
|
intent.arch === 'upper' ||
|
||||||
|
intent.arch === 'lower' ||
|
||||||
|
intent.arch === 'both';
|
||||||
|
return archGiven && !Number.isInteger(intent.position);
|
||||||
|
}
|
||||||
|
|
||||||
/** Compose every resolver into the payload the review sheet renders. */
|
/** Compose every resolver into the payload the review sheet renders. */
|
||||||
export function resolveVoiceIntent(
|
export function resolveVoiceIntent(
|
||||||
intent: VoiceIntent,
|
intent: VoiceIntent,
|
||||||
@@ -488,7 +507,9 @@ export function resolveVoiceIntent(
|
|||||||
): ResolvedExtraction {
|
): ResolvedExtraction {
|
||||||
const unresolved: UnresolvedItem[] = [];
|
const unresolved: UnresolvedItem[] = [];
|
||||||
|
|
||||||
const toothResult = resolveToothIntents(intent?.teeth ?? []);
|
const toothResult = resolveToothIntents(
|
||||||
|
(intent?.teeth ?? []).filter((tooth) => !isJawReference(tooth)),
|
||||||
|
);
|
||||||
unresolved.push(...toothResult.unresolved);
|
unresolved.push(...toothResult.unresolved);
|
||||||
|
|
||||||
const spanResult = resolveConnectedSpans(
|
const spanResult = resolveConnectedSpans(
|
||||||
|
|||||||
@@ -135,7 +135,13 @@ export const VOICE_INTENT_JSON_SCHEMA = {
|
|||||||
type: ['string', 'null'],
|
type: ['string', 'null'],
|
||||||
description: 'A treatment type CODE from the supplied list, or null.',
|
description: 'A treatment type CODE from the supplied list, or null.',
|
||||||
},
|
},
|
||||||
teeth: { type: 'array', items: TOOTH_SCHEMA },
|
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: {
|
connectedSpans: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Bridges / splinted units. Endpoints inclusive.',
|
description: 'Bridges / splinted units. Endpoints inclusive.',
|
||||||
|
|||||||
Reference in New Issue
Block a user