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:
@@ -7,6 +7,7 @@ const LOCALE_NOTES: Record<string, string> = {
|
|||||||
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
|
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
|
||||||
'Digits may appear in Persian or Latin script. Two-digit FDI notation ("یک چهار") does',
|
'Digits may appear in Persian or Latin script. Two-digit FDI notation ("یک چهار") does',
|
||||||
'occur — use the "fdi" field only for that.',
|
'occur — use the "fdi" field only for that.',
|
||||||
|
'A bare "دندون دو" carries no quadrant: report position 2 with arch and side null.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
nl: [
|
nl: [
|
||||||
'The clinician is speaking Dutch and uses FDI notation, which is standard in the',
|
'The clinician is speaking Dutch and uses FDI notation, which is standard in the',
|
||||||
@@ -50,6 +51,9 @@ export function buildExtractionPrompt(
|
|||||||
' what was heard.',
|
' what was heard.',
|
||||||
'6. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
|
'6. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
|
||||||
' one is not.',
|
' 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,
|
localeNote,
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -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', () => {
|
it('keeps unresolved items separate when the model omits the spoken span', () => {
|
||||||
// Without `spoken` these are indistinguishable; collapsing them would hide a lost tooth.
|
// Without `spoken` these are indistinguishable; collapsing them would hide a lost tooth.
|
||||||
const result = resolveToothIntents([
|
const result = resolveToothIntents([
|
||||||
|
|||||||
@@ -55,7 +55,14 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
|
|||||||
!Number.isInteger(intent.position) ||
|
!Number.isInteger(intent.position) ||
|
||||||
intent.position < 1 ||
|
intent.position < 1 ||
|
||||||
intent.position > 8;
|
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';
|
return 'malformed';
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export type VoiceIntent = {
|
|||||||
export type UnresolvedReason =
|
export type UnresolvedReason =
|
||||||
| 'not_permanent_tooth'
|
| 'not_permanent_tooth'
|
||||||
| 'position_out_of_range'
|
| 'position_out_of_range'
|
||||||
|
/** A position was understood but no quadrant was spoken — four teeth match. */
|
||||||
|
| 'tooth_missing_quadrant'
|
||||||
| 'malformed'
|
| 'malformed'
|
||||||
| 'span_not_same_arch'
|
| 'span_not_same_arch'
|
||||||
| 'unknown_catalog_code'
|
| 'unknown_catalog_code'
|
||||||
|
|||||||
@@ -914,6 +914,7 @@
|
|||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "not a permanent tooth",
|
"not_permanent_tooth": "not a permanent tooth",
|
||||||
"position_out_of_range": "not a valid tooth position",
|
"position_out_of_range": "not a valid tooth position",
|
||||||
|
"tooth_missing_quadrant": "quadrant not said — e.g. “upper right two”",
|
||||||
"malformed": "could not be read",
|
"malformed": "could not be read",
|
||||||
"span_not_same_arch": "a bridge cannot span both jaws",
|
"span_not_same_arch": "a bridge cannot span both jaws",
|
||||||
"unknown_catalog_code": "not in this clinic’s list",
|
"unknown_catalog_code": "not in this clinic’s list",
|
||||||
|
|||||||
@@ -915,6 +915,7 @@
|
|||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "دندان دائمی نیست",
|
"not_permanent_tooth": "دندان دائمی نیست",
|
||||||
"position_out_of_range": "شماره دندان معتبر نیست",
|
"position_out_of_range": "شماره دندان معتبر نیست",
|
||||||
|
"tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»",
|
||||||
"malformed": "قابل خواندن نبود",
|
"malformed": "قابل خواندن نبود",
|
||||||
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
||||||
"unknown_catalog_code": "در فهرست این مطب نیست",
|
"unknown_catalog_code": "در فهرست این مطب نیست",
|
||||||
|
|||||||
@@ -914,6 +914,7 @@
|
|||||||
"voiceUnresolved": {
|
"voiceUnresolved": {
|
||||||
"not_permanent_tooth": "geen blijvend element",
|
"not_permanent_tooth": "geen blijvend element",
|
||||||
"position_out_of_range": "geen geldige elementpositie",
|
"position_out_of_range": "geen geldige elementpositie",
|
||||||
|
"tooth_missing_quadrant": "kwadrant niet genoemd — bijv. “rechtsboven twee”",
|
||||||
"malformed": "kon niet worden gelezen",
|
"malformed": "kon niet worden gelezen",
|
||||||
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
|
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
|
||||||
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
|
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FdiToothId, ToothSelectionGroup } from '@/types/treatment';
|
|||||||
export type VoiceUnresolvedReason =
|
export type VoiceUnresolvedReason =
|
||||||
| 'not_permanent_tooth'
|
| 'not_permanent_tooth'
|
||||||
| 'position_out_of_range'
|
| 'position_out_of_range'
|
||||||
|
| 'tooth_missing_quadrant'
|
||||||
| 'malformed'
|
| 'malformed'
|
||||||
| 'span_not_same_arch'
|
| 'span_not_same_arch'
|
||||||
| 'unknown_catalog_code'
|
| 'unknown_catalog_code'
|
||||||
|
|||||||
Reference in New Issue
Block a user