feat(treatment): add voice detail entry #65
169
backend/src/modules/voice/tooth-intent.resolver.spec.ts
Normal file
169
backend/src/modules/voice/tooth-intent.resolver.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
resolveToothIntent,
|
||||||
|
resolveToothIntents,
|
||||||
|
} from './tooth-intent.resolver';
|
||||||
|
import type { ToothIntent } from './voice.types';
|
||||||
|
|
||||||
|
const positional = (
|
||||||
|
arch: 'upper' | 'lower',
|
||||||
|
side: 'patient_right' | 'patient_left',
|
||||||
|
position: number,
|
||||||
|
spoken = 'x',
|
||||||
|
): ToothIntent => ({ kind: 'positional', arch, side, position, spoken });
|
||||||
|
|
||||||
|
const explicit = (fdi: string, spoken = 'x'): ToothIntent => ({
|
||||||
|
kind: 'explicit',
|
||||||
|
fdi,
|
||||||
|
spoken,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveToothIntent', () => {
|
||||||
|
describe('positional intents', () => {
|
||||||
|
// "شش بالا راست" — upper right six — must be 16, not 26. A mirrored quadrant is a
|
||||||
|
// valid code for the wrong tooth and reaches the lab unnoticed.
|
||||||
|
it('resolves each quadrant from the patient perspective', () => {
|
||||||
|
expect(resolveToothIntent(positional('upper', 'patient_right', 6))).toBe(
|
||||||
|
'16',
|
||||||
|
);
|
||||||
|
expect(resolveToothIntent(positional('upper', 'patient_left', 6))).toBe(
|
||||||
|
'26',
|
||||||
|
);
|
||||||
|
expect(resolveToothIntent(positional('lower', 'patient_left', 6))).toBe(
|
||||||
|
'36',
|
||||||
|
);
|
||||||
|
expect(resolveToothIntent(positional('lower', 'patient_right', 6))).toBe(
|
||||||
|
'46',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an out-of-range position instead of clamping', () => {
|
||||||
|
expect(
|
||||||
|
resolveToothIntent(positional('upper', 'patient_right', 9)),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveToothIntent(positional('upper', 'patient_right', 0)),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a malformed arch or side', () => {
|
||||||
|
expect(
|
||||||
|
resolveToothIntent(
|
||||||
|
positional('sideways' as 'upper', 'patient_right', 6),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveToothIntent(
|
||||||
|
positional('upper', 'viewer_right' as 'patient_right', 6),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('explicit intents', () => {
|
||||||
|
it('accepts a permanent FDI code', () => {
|
||||||
|
expect(resolveToothIntent(explicit('14'))).toBe('14');
|
||||||
|
expect(resolveToothIntent(explicit('48'))).toBe('48');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects deciduous codes rather than snapping to a permanent tooth', () => {
|
||||||
|
expect(resolveToothIntent(explicit('51'))).toBeNull();
|
||||||
|
expect(resolveToothIntent(explicit('85'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects nonsense', () => {
|
||||||
|
for (const value of ['', '1', '99', '140']) {
|
||||||
|
expect(resolveToothIntent(explicit(value))).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a missing or unknown intent shape', () => {
|
||||||
|
expect(resolveToothIntent(undefined as unknown as ToothIntent)).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolveToothIntent({ kind: 'guess' } as unknown as ToothIntent),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveToothIntents', () => {
|
||||||
|
it('resolves a mixed list and sorts the result', () => {
|
||||||
|
const result = resolveToothIntents([
|
||||||
|
positional('upper', 'patient_right', 5, 'پنج بالا راست'),
|
||||||
|
explicit('14', 'یک چهار'),
|
||||||
|
]);
|
||||||
|
expect(result.teeth).toEqual(['14', '15']);
|
||||||
|
expect(result.unresolved).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses a tooth named twice', () => {
|
||||||
|
const result = resolveToothIntents([
|
||||||
|
explicit('14', 'چهارده'),
|
||||||
|
positional('upper', 'patient_right', 4, 'چهار بالا راست'),
|
||||||
|
]);
|
||||||
|
expect(result.teeth).toEqual(['14']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports what it could not understand instead of dropping it', () => {
|
||||||
|
const result = resolveToothIntents([
|
||||||
|
explicit('14', 'یک چهار'),
|
||||||
|
explicit('51', 'دندان شیری'),
|
||||||
|
positional('upper', 'patient_right', 9, 'نه بالا راست'),
|
||||||
|
]);
|
||||||
|
expect(result.teeth).toEqual(['14']);
|
||||||
|
expect(result.unresolved).toEqual([
|
||||||
|
{ spoken: 'دندان شیری', reason: 'not_permanent_tooth' },
|
||||||
|
{ spoken: 'نه بالا راست', reason: 'position_out_of_range' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repeat an identical unresolved item', () => {
|
||||||
|
const result = resolveToothIntents([
|
||||||
|
explicit('51', 'شیری'),
|
||||||
|
explicit('51', 'شیری'),
|
||||||
|
]);
|
||||||
|
expect(result.unresolved).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a non-array where the model should have sent a list', () => {
|
||||||
|
// The model can return an object or a number here; that must degrade, not 500.
|
||||||
|
for (const bad of [undefined, null, 5, 'teeth', { fdi: '14' }]) {
|
||||||
|
expect(resolveToothIntents(bad as unknown as ToothIntent[])).toEqual({
|
||||||
|
teeth: [],
|
||||||
|
unresolved: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims an explicit code, matching normalizeTeeth', () => {
|
||||||
|
expect(resolveToothIntents([explicit(' 14 ', 'x')]).teeth).toEqual(['14']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes a deciduous tooth from nonsense in the reason it reports', () => {
|
||||||
|
// '51' really is a (primary) tooth the chart cannot show; '99' is not a tooth at all.
|
||||||
|
expect(
|
||||||
|
resolveToothIntents([explicit('51', 'shiri')]).unresolved[0].reason,
|
||||||
|
).toBe('not_permanent_tooth');
|
||||||
|
for (const junk of ['99', '19', '140', '', 'ab']) {
|
||||||
|
expect(
|
||||||
|
resolveToothIntents([explicit(junk, `j-${junk}`)]).unresolved[0].reason,
|
||||||
|
).toBe('malformed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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([
|
||||||
|
{ kind: 'explicit', fdi: '51' } as ToothIntent,
|
||||||
|
{ kind: 'explicit', fdi: '52' } as ToothIntent,
|
||||||
|
]);
|
||||||
|
expect(result.unresolved).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty or missing list', () => {
|
||||||
|
expect(resolveToothIntents([])).toEqual({ teeth: [], unresolved: [] });
|
||||||
|
expect(resolveToothIntents(undefined as unknown as ToothIntent[])).toEqual({
|
||||||
|
teeth: [],
|
||||||
|
unresolved: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
108
backend/src/modules/voice/tooth-intent.resolver.ts
Normal file
108
backend/src/modules/voice/tooth-intent.resolver.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { isFdiTooth, toFdi } from '../../common/fdi';
|
||||||
|
import type { ToothIntent, UnresolvedItem } from './voice.types';
|
||||||
|
|
||||||
|
export type ToothResolution = {
|
||||||
|
/** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */
|
||||||
|
teeth: string[];
|
||||||
|
unresolved: UnresolvedItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one spoken tooth reference to an FDI code, or null.
|
||||||
|
*
|
||||||
|
* Never guesses and never clamps: a position of 9, a deciduous tooth, or a malformed
|
||||||
|
* intent resolves to null so the caller can surface it as "not understood" rather than
|
||||||
|
* silently selecting a neighbouring tooth.
|
||||||
|
*/
|
||||||
|
export function resolveToothIntent(intent: ToothIntent): string | null {
|
||||||
|
if (!intent || typeof intent !== 'object') return null;
|
||||||
|
|
||||||
|
if (intent.kind === 'explicit') {
|
||||||
|
const fdi = normalizedFdi(intent);
|
||||||
|
return isFdiTooth(fdi) ? fdi : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.kind === 'positional') {
|
||||||
|
if (intent.arch !== 'upper' && intent.arch !== 'lower') return null;
|
||||||
|
if (intent.side !== 'patient_right' && intent.side !== 'patient_left')
|
||||||
|
return null;
|
||||||
|
return toFdi(intent.arch, intent.side, intent.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
|
||||||
|
if (!intent || typeof intent !== 'object') return 'malformed';
|
||||||
|
|
||||||
|
if (intent.kind === 'explicit') {
|
||||||
|
const fdi = normalizedFdi(intent);
|
||||||
|
// Quadrants 1-4 are permanent and would already have resolved, so a well-formed
|
||||||
|
// quadrant+position reaching here is quadrant 5-8: deciduous. Anything else is noise.
|
||||||
|
return /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.kind === 'positional') {
|
||||||
|
const positionBad =
|
||||||
|
!Number.isInteger(intent.position) ||
|
||||||
|
intent.position < 1 ||
|
||||||
|
intent.position > 8;
|
||||||
|
return positionBad ? 'position_out_of_range' : 'malformed';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'malformed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function spokenOf(intent: ToothIntent): string {
|
||||||
|
const spoken = (intent as { spoken?: unknown })?.spoken;
|
||||||
|
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a list of spoken tooth references.
|
||||||
|
*
|
||||||
|
* Duplicates collapse — a clinician may name the same tooth twice in one sentence — and
|
||||||
|
* anything unresolvable is reported rather than dropped, so the review sheet can show the
|
||||||
|
* user exactly which words were not understood.
|
||||||
|
*/
|
||||||
|
export function resolveToothIntents(
|
||||||
|
intents: readonly ToothIntent[],
|
||||||
|
): ToothResolution {
|
||||||
|
const teeth = new Set<string>();
|
||||||
|
const unresolved: UnresolvedItem[] = [];
|
||||||
|
const seenUnresolved = new Set<string>();
|
||||||
|
|
||||||
|
// Not `intents ?? []`: a model may return an object or a number here, and a
|
||||||
|
// non-iterable must degrade like any other malformed shape rather than throw.
|
||||||
|
const list: readonly ToothIntent[] = Array.isArray(intents)
|
||||||
|
? (intents as readonly ToothIntent[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const intent of list) {
|
||||||
|
const fdi = resolveToothIntent(intent);
|
||||||
|
if (fdi) {
|
||||||
|
teeth.add(fdi);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const reason = unresolvedReason(intent);
|
||||||
|
const spoken = spokenOf(intent);
|
||||||
|
// Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost
|
||||||
|
// references would collapse into one blank review row and a tooth would vanish.
|
||||||
|
if (spoken) {
|
||||||
|
const key = `${spoken}::${reason}`;
|
||||||
|
if (seenUnresolved.has(key)) continue;
|
||||||
|
seenUnresolved.add(key);
|
||||||
|
}
|
||||||
|
unresolved.push({ spoken, reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { teeth: [...teeth].sort(), unresolved };
|
||||||
|
}
|
||||||
77
backend/src/modules/voice/voice.types.ts
Normal file
77
backend/src/modules/voice/voice.types.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Arch, PatientSide } from '../../common/fdi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the extraction model is allowed to return.
|
||||||
|
*
|
||||||
|
* The model emits *intents*, never resolved values: no FDI codes, no ISO dates. Pure,
|
||||||
|
* unit-tested resolvers turn intents into domain values, so the two highest-consequence
|
||||||
|
* mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */
|
||||||
|
export type ToothIntent =
|
||||||
|
| { kind: 'explicit'; fdi: string; spoken: string }
|
||||||
|
| {
|
||||||
|
kind: 'positional';
|
||||||
|
arch: Arch;
|
||||||
|
side: PatientSide;
|
||||||
|
/** 1 = central incisor … 8 = third molar. */
|
||||||
|
position: number;
|
||||||
|
spoken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A spoken deadline. The model never does calendar arithmetic. */
|
||||||
|
export type DueIntent =
|
||||||
|
| { kind: 'weekday'; weekday: Weekday; which: 'this' | 'next' }
|
||||||
|
| { kind: 'offset'; unit: 'day' | 'week' | 'month'; amount: number }
|
||||||
|
| { kind: 'jalali'; jy: number; jm: number; jd: number }
|
||||||
|
| { kind: 'gregorian'; y: number; m: number; d: number };
|
||||||
|
|
||||||
|
export const WEEKDAYS = [
|
||||||
|
'saturday',
|
||||||
|
'sunday',
|
||||||
|
'monday',
|
||||||
|
'tuesday',
|
||||||
|
'wednesday',
|
||||||
|
'thursday',
|
||||||
|
'friday',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Weekday = (typeof WEEKDAYS)[number];
|
||||||
|
|
||||||
|
/** Two teeth defining an inclusive connected (bridge) span. */
|
||||||
|
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
|
||||||
|
|
||||||
|
export type ProsthesisIntent = {
|
||||||
|
/** Catalog code applied to every tooth unless overridden. */
|
||||||
|
defaultType: string | null;
|
||||||
|
overrides: { tooth: ToothIntent; type: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VoiceIntent = {
|
||||||
|
treatmentType: string | null;
|
||||||
|
teeth: ToothIntent[];
|
||||||
|
connectedSpans: ConnectedSpanIntent[];
|
||||||
|
comment: string | null;
|
||||||
|
prosthesis: ProsthesisIntent | null;
|
||||||
|
/** Must be one of the linked-lab ids supplied in the prompt, or null. */
|
||||||
|
labId: string | null;
|
||||||
|
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */
|
||||||
|
labMatchExact: boolean;
|
||||||
|
due: DueIntent | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Why a spoken item could not be turned into a domain value. Shown to the user. */
|
||||||
|
export type UnresolvedReason =
|
||||||
|
| 'not_permanent_tooth'
|
||||||
|
| 'position_out_of_range'
|
||||||
|
| 'malformed'
|
||||||
|
| 'span_not_same_arch'
|
||||||
|
| 'unknown_catalog_code'
|
||||||
|
| 'invalid_date';
|
||||||
|
|
||||||
|
export type UnresolvedItem = {
|
||||||
|
/** The transcript span that could not be resolved, so the user can see what was heard. */
|
||||||
|
spoken: string;
|
||||||
|
reason: UnresolvedReason;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user