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:
2026-09-10 16:56:54 +08:00
parent fbe423bc95
commit 76d929de3a
4 changed files with 109 additions and 2 deletions

View File

@@ -601,6 +601,84 @@ describe('resolveVoiceIntent', () => {
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', () => {
const result = resolveVoiceIntent(
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },