fix(backend): read a tooth code whatever script its digits are in
The extraction model transcribes Persian speech, so it can hand back "۲۶" in Persian digits or "2 6" from a digit-by-digit dictation. Both were compared literally against /^[1-8][1-8]$/, missed, and fell through to the positional branch with no quadrant — where the tooth was reported as "not understood". The clinician loses a tooth and is told the words were the problem. normalizeFdiCode() now runs at both the branch choice and the final validation, so the two cannot disagree. toLatinDigits moves out of jalali.ts into common/digits.ts: it was exported but unused in production, and a tooth module reaching into the calendar module would read as an accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,33 @@ describe('VOICE_INTENT_JSON_SCHEMA', () => {
|
||||
});
|
||||
|
||||
describe('toVoiceIntent', () => {
|
||||
it('takes the explicit branch for a code the model wrote in Persian digits', () => {
|
||||
// The model is reading Persian text back, so "۲۶" and a digit-by-digit "2 6" both
|
||||
// reach us. Matching only ASCII drops the tooth into the positional branch with no
|
||||
// quadrant, where it is reported as unresolved — the clinician loses a tooth and is
|
||||
// told the words were the problem.
|
||||
for (const raw of ['\u06F2\u06F6', '2 6', ' 26 ', '\u0662\u0666']) {
|
||||
const [tooth] = toVoiceIntent(
|
||||
wire({
|
||||
teeth: [
|
||||
{
|
||||
spoken: '\u0628\u06CC\u0633\u062A \u0648 \u0634\u0634',
|
||||
fdi: raw,
|
||||
arch: null,
|
||||
side: null,
|
||||
position: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).teeth;
|
||||
expect(tooth).toEqual({
|
||||
kind: 'explicit',
|
||||
fdi: '26',
|
||||
spoken: '\u0628\u06CC\u0633\u062A \u0648 \u0634\u0634',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('narrows a positional tooth', () => {
|
||||
const result = toVoiceIntent(wire({ teeth: [positionalTooth] }));
|
||||
expect(result.teeth[0]).toEqual({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeFdiCode } from '../../common/fdi';
|
||||
import type {
|
||||
ConnectedSpanIntent,
|
||||
DueIntent,
|
||||
@@ -180,7 +181,10 @@ const FDI_SHAPE = /^[1-8][1-8]$/;
|
||||
|
||||
function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
|
||||
const spoken = typeof wire?.spoken === 'string' ? wire.spoken : '';
|
||||
const fdi = typeof wire?.fdi === 'string' ? wire.fdi.trim() : '';
|
||||
// Persian digits and digit-by-digit dictation ("۲۶", "2 6") are FDI codes that do not
|
||||
// match literally; without normalising first they fall through to the positional branch
|
||||
// with no quadrant and are reported 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)) {
|
||||
|
||||
@@ -134,6 +134,14 @@ describe('resolveToothIntents', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reads a spoken number as its FDI code, digits in any script', () => {
|
||||
// The product rule: the number the clinician says IS the tooth. 26 = quadrant 2
|
||||
// (patient's upper left) + position 6 = first molar.
|
||||
for (const raw of ['26', ' 26 ', '2 6', '\u06F2\u06F6', '\u0662\u0666']) {
|
||||
expect(resolveToothIntents([explicit(raw, 'x')]).teeth).toEqual(['26']);
|
||||
}
|
||||
});
|
||||
|
||||
it('trims an explicit code, matching normalizeTeeth', () => {
|
||||
expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isFdiTooth, toFdi } from '../../common/fdi';
|
||||
import { isFdiTooth, normalizeFdiCode, toFdi } from '../../common/fdi';
|
||||
import type { ToothIntent, UnresolvedItem } from './voice.types';
|
||||
|
||||
export type ToothResolution = {
|
||||
@@ -9,10 +9,10 @@ export type ToothResolution = {
|
||||
|
||||
/** Everything here parses untrusted model output, so nothing may throw. */
|
||||
function normalizedFdi(intent: ToothIntent): string {
|
||||
const raw = (intent as { fdi?: unknown }).fdi;
|
||||
// Trimmed for parity with normalizeTeeth — '14 ' is tooth 14 through the treatment API
|
||||
// and must not be "malformed" here.
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
// Same normalisation the wire layer used to pick this branch, so the two cannot
|
||||
// disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and
|
||||
// neither may be reported as malformed here.
|
||||
return normalizeFdiCode((intent as { fdi?: unknown }).fdi);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user