fix(voice): say the quadrant is missing instead of "could not be read"

"ترمیم برای دندون دو" set the treatment type but reported the tooth as
unreadable. Nothing was misheard: position 2 arrived intact, with no
quadrant, because none was spoken — four teeth carry position 2 and the
resolver correctly refused to pick one. Only the label was wrong, and it
sent the clinician looking for a transcription fault.

Adds a tooth_missing_quadrant reason that names what is missing and shows
how to say it ("دو بالا راست"), and tells the model explicitly to report a
quadrant-less number with arch and side null rather than guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 04:02:39 +08:00
parent afb30691cf
commit 1be735a7ab
8 changed files with 69 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ const LOCALE_NOTES: Record<string, string> = {
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
'Digits may appear in Persian or Latin script. Two-digit FDI notation ("یک چهار") does',
'occur — use the "fdi" field only for that.',
'A bare "دندون دو" carries no quadrant: report position 2 with arch and side null.',
].join(' '),
nl: [
'The clinician is speaking Dutch and uses FDI notation, which is standard in the',
@@ -50,6 +51,9 @@ export function buildExtractionPrompt(
' what was heard.',
'6. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
' one is not.',
'7. A tooth number spoken WITHOUT a quadrant ("دندون دو", "tooth two") does not identify',
' a tooth — four teeth carry that position. Still report it: set "position" and leave',
' "arch" and "side" null. Never pick a quadrant that was not said.',
'',
localeNote,
'',

View File

@@ -150,6 +150,57 @@ describe('resolveToothIntents', () => {
}
});
it('says the quadrant is missing rather than blaming the words', () => {
// Regression: "ترمیم برای دندون دو" reported "could not be read", sending the
// clinician to look for a transcription fault. Position 2 was understood fine —
// what is missing is the quadrant, and four teeth carry position 2.
const bare = {
kind: 'positional',
arch: null,
side: null,
position: 2,
spoken: 'دندون دو',
} as unknown as ToothIntent;
const result = resolveToothIntents([bare]);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([
{ spoken: 'دندون دو', reason: 'tooth_missing_quadrant' },
]);
});
it('reports a missing quadrant for a half-specified tooth too', () => {
// "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess.
for (const half of [
{ arch: 'upper', side: null },
{ arch: null, side: 'patient_right' },
]) {
const result = resolveToothIntents([
{
kind: 'positional',
...half,
position: 2,
spoken: 'دو',
} as unknown as ToothIntent,
]);
expect(result.unresolved[0].reason).toBe('tooth_missing_quadrant');
}
});
it('still calls an out-of-range position out of range when the quadrant is missing', () => {
// Position wins: "nine" is wrong however completely it was said.
const result = resolveToothIntents([
{
kind: 'positional',
arch: null,
side: null,
position: 9,
spoken: 'نه',
} as unknown as ToothIntent,
]);
expect(result.unresolved[0].reason).toBe('position_out_of_range');
});
it('keeps unresolved items separate when the model omits the spoken span', () => {
// Without `spoken` these are indistinguishable; collapsing them would hide a lost tooth.
const result = resolveToothIntents([

View File

@@ -55,7 +55,14 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
!Number.isInteger(intent.position) ||
intent.position < 1 ||
intent.position > 8;
return positionBad ? 'position_out_of_range' : 'malformed';
if (positionBad) return 'position_out_of_range';
// The position was understood, so the words were not the problem: the speaker never
// said which quadrant. "دندون دو" names four teeth at once, and telling the
// clinician it "could not be read" would send them looking for the wrong fault.
const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower';
const sideMissing =
intent.side !== 'patient_right' && intent.side !== 'patient_left';
return archMissing || sideMissing ? 'tooth_missing_quadrant' : 'malformed';
}
return 'malformed';

View File

@@ -65,6 +65,8 @@ export type VoiceIntent = {
export type UnresolvedReason =
| 'not_permanent_tooth'
| 'position_out_of_range'
/** A position was understood but no quadrant was spoken — four teeth match. */
| 'tooth_missing_quadrant'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'