feat(backend): assemble resolved extraction from voice intents
Composes the tooth, span, prosthesis, catalog and date resolvers into the payload the review sheet renders. Connected spans expand: "a bridge from 14 to 16" selects 15, which was never spoken. Overlapping spans merge into one bridge, group teeth sort along the arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to a single tooth degrades to a single group without losing that tooth — there is no such thing as a one-tooth bridge. A cross-arch span is impossible and is reported rather than guessed at. Prosthesis expands a default across the selection then applies per-tooth overrides, because "همه زیرکونیا، ۲۶ پیافام" is how clinicians actually speak. Completeness is computed here so an unshippable map surfaces at review rather than failing later at dispatch. Everything the model names is checked against the catalog we supplied it, and anything rejected is reported rather than dropped — a hallucinated lab id must not look identical to "no lab was spoken", since silence and a wrong lab lead to very different corrective actions. Also fixed, from review of this commit: - an empty prosthesis object no longer fabricates an "incomplete, cannot ship" warning on a plain restoration - an override naming a tooth outside the selection now reports tooth_not_selected rather than malformed; the clinician was understood, the tooth just is not on this detail - a due object with no `kind` is treated as no deadline rather than a blank "heard but lost" row; an unrecognised kind is still flagged, and named Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
areArchNeighbors,
|
areArchNeighbors,
|
||||||
|
sortInArchOrder,
|
||||||
FDI_TOOTH_IDS,
|
FDI_TOOTH_IDS,
|
||||||
isFdiTooth,
|
isFdiTooth,
|
||||||
sameArch,
|
sameArch,
|
||||||
@@ -122,4 +123,22 @@ describe('FDI geometry', () => {
|
|||||||
expect(teethBetweenInclusive('99', '14')).toBeNull();
|
expect(teethBetweenInclusive('99', '14')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('sortInArchOrder', () => {
|
||||||
|
it('sorts along the arch rather than lexically', () => {
|
||||||
|
expect(sortInArchOrder(['14', '16', '15'])).toEqual(['16', '15', '14']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places 11 beside 21 across the midline', () => {
|
||||||
|
expect(sortInArchOrder(['21', '11', '12'])).toEqual(['12', '11', '21']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op for an empty or single-tooth list', () => {
|
||||||
|
expect(sortInArchOrder([])).toEqual([]);
|
||||||
|
expect(sortInArchOrder(['14'])).toEqual(['14']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not drop teeth it cannot place', () => {
|
||||||
|
expect(sortInArchOrder(['99', '14']).sort()).toEqual(['14', '99']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,3 +118,18 @@ export function toFdi(
|
|||||||
const code = `${quadrant}${position}`;
|
const code = `${quadrant}${position}`;
|
||||||
return FDI_TOOTH_IDS.has(code) ? code : null;
|
return FDI_TOOTH_IDS.has(code) ? code : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort teeth along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside
|
||||||
|
* 21 across the midline. Teeth from another arch (or unknown) sort to the end, stably.
|
||||||
|
*/
|
||||||
|
export function sortInArchOrder(teeth: readonly string[]): string[] {
|
||||||
|
if (teeth.length === 0) return [];
|
||||||
|
const arch = teeth.map((t) => archOrder(t)).find((a) => a !== null) ?? null;
|
||||||
|
if (!arch) return [...teeth];
|
||||||
|
const indexOf = (tooth: string) => {
|
||||||
|
const i = arch.indexOf(tooth);
|
||||||
|
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
||||||
|
};
|
||||||
|
return [...teeth].sort((a, b) => indexOf(a) - indexOf(b));
|
||||||
|
}
|
||||||
|
|||||||
@@ -239,6 +239,24 @@ describe('resolveDueDate', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats an object with no kind as no deadline, not a lost one', () => {
|
||||||
|
// A blank "heard but lost" row in front of a clinician who never mentioned a
|
||||||
|
// deadline is worse than saying nothing.
|
||||||
|
expect(resolveDueDate({} as never, SATURDAY)).toEqual({
|
||||||
|
dueDate: null,
|
||||||
|
unresolved: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags an unrecognised kind and names it', () => {
|
||||||
|
const result = resolveDueDate({ kind: 'lunar_month' } as never, SATURDAY);
|
||||||
|
expect(result.dueDate).toBeNull();
|
||||||
|
expect(result.unresolved).toEqual({
|
||||||
|
spoken: 'lunar_month',
|
||||||
|
reason: 'invalid_date',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('flags a non-object deadline instead of silently dropping it', () => {
|
it('flags a non-object deadline instead of silently dropping it', () => {
|
||||||
// A bare string is a deadline we failed to parse, not an absent one — the clinician
|
// A bare string is a deadline we failed to parse, not an absent one — the clinician
|
||||||
// must see that something was heard and lost.
|
// must see that something was heard and lost.
|
||||||
|
|||||||
@@ -87,8 +87,12 @@ function describe(intent: DueIntent): string {
|
|||||||
return `${intent.jy}/${intent.jm}/${intent.jd}`;
|
return `${intent.jy}/${intent.jm}/${intent.jd}`;
|
||||||
case 'gregorian':
|
case 'gregorian':
|
||||||
return `${intent.y}-${intent.m}-${intent.d}`;
|
return `${intent.y}-${intent.m}-${intent.d}`;
|
||||||
default:
|
default: {
|
||||||
return '';
|
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
|
||||||
|
// established is a string — echo it so the review row names what was heard.
|
||||||
|
const kind = (intent as { kind?: unknown })?.kind;
|
||||||
|
return typeof kind === 'string' ? kind : '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +166,12 @@ export function resolveDueDate(
|
|||||||
if (typeof intent !== 'object') {
|
if (typeof intent !== 'object') {
|
||||||
return unresolved(String(intent).slice(0, 120));
|
return unresolved(String(intent).slice(0, 120));
|
||||||
}
|
}
|
||||||
|
// An object carrying no `kind` at all says nothing about a deadline; flagging it would
|
||||||
|
// put a blank "heard but lost" row in front of a clinician who never mentioned one. An
|
||||||
|
// object with an *unrecognised* kind did try to say something, and is flagged below.
|
||||||
|
if (typeof (intent as { kind?: unknown }).kind !== 'string') {
|
||||||
|
return { dueDate: null, unresolved: null };
|
||||||
|
}
|
||||||
if (!isRealCivilDate(todayIso)) {
|
if (!isRealCivilDate(todayIso)) {
|
||||||
return unresolved(describe(intent));
|
return unresolved(describe(intent));
|
||||||
}
|
}
|
||||||
|
|||||||
353
backend/src/modules/voice/extraction.resolver.spec.ts
Normal file
353
backend/src/modules/voice/extraction.resolver.spec.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import {
|
||||||
|
resolveConnectedSpans,
|
||||||
|
resolveProsthesis,
|
||||||
|
resolveVoiceIntent,
|
||||||
|
type ResolveContext,
|
||||||
|
} from './extraction.resolver';
|
||||||
|
import type { ToothIntent, VoiceIntent } from './voice.types';
|
||||||
|
|
||||||
|
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
||||||
|
kind: 'explicit',
|
||||||
|
fdi,
|
||||||
|
spoken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CTX: ResolveContext = {
|
||||||
|
todayIso: '2025-10-11',
|
||||||
|
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
|
||||||
|
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
|
||||||
|
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('resolveConnectedSpans', () => {
|
||||||
|
it('selects the teeth between the endpoints, which were never named', () => {
|
||||||
|
// "a bridge from 14 to 16" must select 15 too.
|
||||||
|
const result = resolveConnectedSpans(
|
||||||
|
[{ from: tooth('14'), to: tooth('16') }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(result.teeth).toEqual(['14', '15', '16']);
|
||||||
|
expect(result.groups).toEqual([
|
||||||
|
{ groupId: 'voice-c1', kind: 'connected', teeth: ['16', '15', '14'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders group teeth along the arch, not lexically', () => {
|
||||||
|
const result = resolveConnectedSpans(
|
||||||
|
[{ from: tooth('16'), to: tooth('14') }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(result.groups[0].teeth).toEqual(['16', '15', '14']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spans the midline', () => {
|
||||||
|
const result = resolveConnectedSpans(
|
||||||
|
[{ from: tooth('12'), to: tooth('22') }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(result.groups[0].teeth).toEqual(['12', '11', '21', '22']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges overlapping spans into one bridge', () => {
|
||||||
|
const result = resolveConnectedSpans(
|
||||||
|
[
|
||||||
|
{ from: tooth('14'), to: tooth('16') },
|
||||||
|
{ from: tooth('15'), to: tooth('17') },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const connected = result.groups.filter((g) => g.kind === 'connected');
|
||||||
|
expect(connected).toHaveLength(1);
|
||||||
|
expect(connected[0].teeth).toEqual(['17', '16', '15', '14']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives loose teeth their own single groups', () => {
|
||||||
|
const result = resolveConnectedSpans(
|
||||||
|
[{ from: tooth('14'), to: tooth('15') }],
|
||||||
|
['26'],
|
||||||
|
);
|
||||||
|
expect(result.groups).toEqual([
|
||||||
|
{ groupId: 'voice-c1', kind: 'connected', teeth: ['15', '14'] },
|
||||||
|
{ groupId: 'voice-s-26', kind: 'single', teeth: ['26'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never produces a one-tooth connected group', () => {
|
||||||
|
const result = resolveConnectedSpans(
|
||||||
|
[{ from: tooth('14'), to: tooth('14') }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(result.groups).toEqual([
|
||||||
|
{ groupId: 'voice-s-14', kind: 'single', teeth: ['14'] },
|
||||||
|
]);
|
||||||
|
expect(result.unresolved).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a cross-arch span rather than guessing', () => {
|
||||||
|
const result = resolveConnectedSpans(
|
||||||
|
[{ from: tooth('14', 'چهارده'), to: tooth('44', 'چهل و چهار') }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(result.groups).toEqual([]);
|
||||||
|
expect(result.unresolved).toEqual([
|
||||||
|
{ spoken: 'چهارده → چهل و چهار', reason: 'span_not_same_arch' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a span with an unresolvable endpoint', () => {
|
||||||
|
const result = resolveConnectedSpans(
|
||||||
|
[{ from: tooth('14'), to: tooth('99') }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(result.unresolved[0].reason).toBe('malformed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a non-array', () => {
|
||||||
|
expect(resolveConnectedSpans(undefined as never, ['14']).teeth).toEqual([
|
||||||
|
'14',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveProsthesis', () => {
|
||||||
|
const allowed = CTX.prosthesisTypeCodes;
|
||||||
|
|
||||||
|
it('expands the default across every tooth', () => {
|
||||||
|
const result = resolveProsthesis(
|
||||||
|
{ defaultType: 'monolithic_zirconia', overrides: [] },
|
||||||
|
['14', '15'],
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
expect(result.prosthesis?.byTooth).toEqual({
|
||||||
|
'14': 'monolithic_zirconia',
|
||||||
|
'15': 'monolithic_zirconia',
|
||||||
|
});
|
||||||
|
expect(result.prosthesis?.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies per-tooth overrides on top of the default', () => {
|
||||||
|
const result = resolveProsthesis(
|
||||||
|
{
|
||||||
|
defaultType: 'monolithic_zirconia',
|
||||||
|
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
|
||||||
|
},
|
||||||
|
['14', '26'],
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
expect(result.prosthesis?.byTooth).toEqual({
|
||||||
|
'14': 'monolithic_zirconia',
|
||||||
|
'26': 'pfm_crown',
|
||||||
|
});
|
||||||
|
expect(result.prosthesis?.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the map incomplete when a tooth ends up untyped', () => {
|
||||||
|
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
|
||||||
|
const result = resolveProsthesis(
|
||||||
|
{
|
||||||
|
defaultType: null,
|
||||||
|
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
|
||||||
|
},
|
||||||
|
['14', '15'],
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
expect(result.prosthesis?.complete).toBe(false);
|
||||||
|
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a catalog code the clinic does not have', () => {
|
||||||
|
const result = resolveProsthesis(
|
||||||
|
{ defaultType: 'gold_foil', overrides: [] },
|
||||||
|
['14'],
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
|
||||||
|
expect(result.prosthesis).toBeNull();
|
||||||
|
expect(result.unresolved).toEqual([
|
||||||
|
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an override for a tooth that is not selected', () => {
|
||||||
|
const result = resolveProsthesis(
|
||||||
|
{
|
||||||
|
defaultType: 'monolithic_zirconia',
|
||||||
|
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
|
||||||
|
},
|
||||||
|
['14'],
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
|
||||||
|
expect(result.unresolved).toEqual([
|
||||||
|
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no prosthesis at all when the object carries nothing usable', () => {
|
||||||
|
// An empty-but-present map would paint a plain restoration with a fabricated
|
||||||
|
// "incomplete, cannot ship" warning.
|
||||||
|
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
|
||||||
|
expect(
|
||||||
|
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
|
||||||
|
).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no prosthesis when there are no teeth to type', () => {
|
||||||
|
const result = resolveProsthesis(
|
||||||
|
{ defaultType: 'monolithic_zirconia', overrides: [] },
|
||||||
|
[],
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
expect(result.prosthesis).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes a tooth it could not understand from one that is not selected', () => {
|
||||||
|
// Different corrective actions: add the tooth, versus repeat yourself.
|
||||||
|
const result = resolveProsthesis(
|
||||||
|
{
|
||||||
|
defaultType: 'monolithic_zirconia',
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
|
||||||
|
type: 'pfm_crown',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
['14'],
|
||||||
|
allowed,
|
||||||
|
);
|
||||||
|
expect(result.unresolved).toEqual([
|
||||||
|
{ spoken: 'نود و نه', reason: 'malformed' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no prosthesis was spoken', () => {
|
||||||
|
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveVoiceIntent', () => {
|
||||||
|
const base: VoiceIntent = {
|
||||||
|
treatmentType: 'restoration',
|
||||||
|
teeth: [tooth('14'), tooth('15')],
|
||||||
|
connectedSpans: [],
|
||||||
|
comment: ' حساسیت به سرما ',
|
||||||
|
prosthesis: null,
|
||||||
|
labId: null,
|
||||||
|
labMatchExact: false,
|
||||||
|
due: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('composes a plain restoration', () => {
|
||||||
|
const result = resolveVoiceIntent(base, CTX);
|
||||||
|
expect(result.treatmentType).toBe('restoration');
|
||||||
|
expect(result.teeth).toEqual(['14', '15']);
|
||||||
|
expect(result.comment).toBe('حساسیت به سرما');
|
||||||
|
expect(result.prosthesis).toBeNull();
|
||||||
|
expect(result.unresolved).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a treatment type outside the catalog', () => {
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{ ...base, treatmentType: 'teeth_whitening' },
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.treatmentType).toBeNull();
|
||||||
|
expect(result.unresolved).toContainEqual({
|
||||||
|
spoken: 'teeth_whitening',
|
||||||
|
reason: 'unknown_catalog_code',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a lab id the clinic is not linked to', () => {
|
||||||
|
// Shipping to a lab the clinic never named is worse than shipping nowhere.
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{ ...base, labId: 'lab-elsewhere', labMatchExact: true },
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.labId).toBeNull();
|
||||||
|
expect(result.labMatchExact).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a linked lab and its exactness flag', () => {
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{ ...base, labId: 'lab-sina', labMatchExact: true },
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.labId).toBe('lab-sina');
|
||||||
|
expect(result.labMatchExact).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a hallucinated lab rather than dropping it silently', () => {
|
||||||
|
// A near-miss lab id must not look identical to "no lab was spoken".
|
||||||
|
const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX);
|
||||||
|
expect(result.unresolved).toContainEqual({
|
||||||
|
spoken: 'lab-elsewhere',
|
||||||
|
reason: 'unknown_catalog_code',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reports an inexact match as exact when the lab was dropped', () => {
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{ ...base, labId: null, labMatchExact: true },
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.labMatchExact).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies prosthesis over the span-expanded tooth set', () => {
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
treatmentType: 'prosthesis',
|
||||||
|
teeth: [tooth('14')],
|
||||||
|
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
|
||||||
|
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
|
||||||
|
},
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
// 15 was never spoken but is part of the bridge, so it must carry a type too.
|
||||||
|
expect(result.teeth).toEqual(['14', '15', '16']);
|
||||||
|
expect(result.prosthesis?.complete).toBe(true);
|
||||||
|
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
|
||||||
|
'14',
|
||||||
|
'15',
|
||||||
|
'16',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a due date through the same context', () => {
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
expect(result.dueDate).toBe('2025-10-16');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects unresolved items from every stage', () => {
|
||||||
|
const result = resolveVoiceIntent(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
treatmentType: 'nope',
|
||||||
|
teeth: [tooth('51', 'شیری')],
|
||||||
|
connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
|
||||||
|
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
|
||||||
|
},
|
||||||
|
CTX,
|
||||||
|
);
|
||||||
|
const reasons = result.unresolved.map((u) => u.reason).sort();
|
||||||
|
expect(reasons).toEqual([
|
||||||
|
'invalid_date',
|
||||||
|
'not_permanent_tooth',
|
||||||
|
'span_not_same_arch',
|
||||||
|
'unknown_catalog_code',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty comment as absent', () => {
|
||||||
|
expect(
|
||||||
|
resolveVoiceIntent({ ...base, comment: ' ' }, CTX).comment,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
305
backend/src/modules/voice/extraction.resolver.ts
Normal file
305
backend/src/modules/voice/extraction.resolver.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import {
|
||||||
|
sameArch,
|
||||||
|
sortInArchOrder,
|
||||||
|
teethBetweenInclusive,
|
||||||
|
} from '../../common/fdi';
|
||||||
|
import { resolveDueDate } from './due-date.resolver';
|
||||||
|
import {
|
||||||
|
resolveToothIntent,
|
||||||
|
resolveToothIntents,
|
||||||
|
} from './tooth-intent.resolver';
|
||||||
|
import type {
|
||||||
|
ConnectedSpanIntent,
|
||||||
|
ProsthesisIntent,
|
||||||
|
ToothIntent,
|
||||||
|
UnresolvedItem,
|
||||||
|
VoiceIntent,
|
||||||
|
} from './voice.types';
|
||||||
|
|
||||||
|
export type ResolvedToothGroup = {
|
||||||
|
groupId: string;
|
||||||
|
kind: 'connected' | 'single';
|
||||||
|
teeth: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedProsthesis = {
|
||||||
|
/** FDI code → prosthesis type code. */
|
||||||
|
byTooth: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* True when every selected tooth carries a code. A prosthesis detail cannot be shipped
|
||||||
|
* otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap
|
||||||
|
* here rather than letting it fail at dispatch.
|
||||||
|
*/
|
||||||
|
complete: boolean;
|
||||||
|
missingTeeth: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedExtraction = {
|
||||||
|
treatmentType: string | null;
|
||||||
|
teeth: string[];
|
||||||
|
toothSelectionGroups: ResolvedToothGroup[];
|
||||||
|
comment: string | null;
|
||||||
|
prosthesis: ResolvedProsthesis | null;
|
||||||
|
labId: string | null;
|
||||||
|
labMatchExact: boolean;
|
||||||
|
dueDate: string | null;
|
||||||
|
unresolved: UnresolvedItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveContext = {
|
||||||
|
todayIso: string;
|
||||||
|
treatmentTypeCodes: ReadonlySet<string>;
|
||||||
|
prosthesisTypeCodes: ReadonlySet<string>;
|
||||||
|
linkedLabIds: ReadonlySet<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function spokenOf(intent: ToothIntent): string {
|
||||||
|
const spoken = (intent as { spoken?: unknown })?.spoken;
|
||||||
|
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A code the model returned is only usable if it exists in the catalog we supplied it. */
|
||||||
|
function resolveCatalogCode(
|
||||||
|
value: unknown,
|
||||||
|
allowed: ReadonlySet<string>,
|
||||||
|
): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed && allowed.has(trimmed) ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge any span sets that share a tooth, so overlapping bridges become one group. */
|
||||||
|
function mergeOverlapping(sets: string[][]): string[][] {
|
||||||
|
const merged: string[][] = [];
|
||||||
|
for (const candidate of sets) {
|
||||||
|
let current = [...candidate];
|
||||||
|
let index = 0;
|
||||||
|
while (index < merged.length) {
|
||||||
|
if (merged[index].some((tooth) => current.includes(tooth))) {
|
||||||
|
current = [...new Set([...merged[index], ...current])];
|
||||||
|
merged.splice(index, 1);
|
||||||
|
index = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
merged.push(current);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn spoken bridge spans plus loose teeth into selection groups.
|
||||||
|
*
|
||||||
|
* Span teeth are added to the selection: saying "a bridge from 14 to 16" selects 15 even
|
||||||
|
* though it was never named. A span whose endpoints are in different arches is impossible
|
||||||
|
* and is reported rather than guessed at. A span that collapses to one tooth degrades to a
|
||||||
|
* single — there is no such thing as a one-tooth bridge.
|
||||||
|
*/
|
||||||
|
export function resolveConnectedSpans(
|
||||||
|
spans: readonly ConnectedSpanIntent[],
|
||||||
|
selectedTeeth: readonly string[],
|
||||||
|
): {
|
||||||
|
groups: ResolvedToothGroup[];
|
||||||
|
teeth: string[];
|
||||||
|
unresolved: UnresolvedItem[];
|
||||||
|
} {
|
||||||
|
const unresolved: UnresolvedItem[] = [];
|
||||||
|
const connectedSets: string[][] = [];
|
||||||
|
// A span that collapses to one tooth still selected that tooth — it must not vanish.
|
||||||
|
const loneSpanTeeth: string[] = [];
|
||||||
|
const list: readonly ConnectedSpanIntent[] = Array.isArray(spans)
|
||||||
|
? (spans as readonly ConnectedSpanIntent[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const span of list) {
|
||||||
|
const from = resolveToothIntent(span?.from);
|
||||||
|
const to = resolveToothIntent(span?.to);
|
||||||
|
const spoken = [spokenOf(span?.from), spokenOf(span?.to)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' → ');
|
||||||
|
|
||||||
|
if (!from || !to) {
|
||||||
|
unresolved.push({ spoken, reason: 'malformed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!sameArch(from, to)) {
|
||||||
|
unresolved.push({ spoken, reason: 'span_not_same_arch' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const between = teethBetweenInclusive(from, to);
|
||||||
|
if (!between || between.length === 0) {
|
||||||
|
unresolved.push({ spoken, reason: 'malformed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (between.length === 1) {
|
||||||
|
loneSpanTeeth.push(between[0]);
|
||||||
|
continue; // degrades to a single, below
|
||||||
|
}
|
||||||
|
connectedSets.push(between);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: ResolvedToothGroup[] = [];
|
||||||
|
const claimed = new Set<string>();
|
||||||
|
mergeOverlapping(connectedSets).forEach((set, i) => {
|
||||||
|
const teeth = sortInArchOrder(set);
|
||||||
|
teeth.forEach((tooth) => claimed.add(tooth));
|
||||||
|
groups.push({ groupId: `voice-c${i + 1}`, kind: 'connected', teeth });
|
||||||
|
});
|
||||||
|
|
||||||
|
const spanTeeth = [...groups.flatMap((g) => g.teeth), ...loneSpanTeeth];
|
||||||
|
const singles = [...new Set([...selectedTeeth, ...spanTeeth])]
|
||||||
|
.filter((tooth) => !claimed.has(tooth))
|
||||||
|
.sort();
|
||||||
|
for (const tooth of singles) {
|
||||||
|
groups.push({
|
||||||
|
groupId: `voice-s-${tooth}`,
|
||||||
|
kind: 'single',
|
||||||
|
teeth: [tooth],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
teeth: [...new Set([...selectedTeeth, ...spanTeeth])].sort(),
|
||||||
|
unresolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a default prosthesis type across the selection, then apply per-tooth overrides.
|
||||||
|
*
|
||||||
|
* "همه زیرکونیا، ۲۶ پیافام" is how clinicians actually speak, so the model names the type
|
||||||
|
* once and overrides the exceptions.
|
||||||
|
*/
|
||||||
|
export function resolveProsthesis(
|
||||||
|
intent: ProsthesisIntent | null | undefined,
|
||||||
|
teeth: readonly string[],
|
||||||
|
allowed: ReadonlySet<string>,
|
||||||
|
): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } {
|
||||||
|
if (!intent || typeof intent !== 'object')
|
||||||
|
return { prosthesis: null, unresolved: [] };
|
||||||
|
|
||||||
|
const unresolved: UnresolvedItem[] = [];
|
||||||
|
const defaultType = resolveCatalogCode(intent.defaultType, allowed);
|
||||||
|
if (intent.defaultType != null && !defaultType) {
|
||||||
|
unresolved.push({
|
||||||
|
spoken: String(intent.defaultType),
|
||||||
|
reason: 'unknown_catalog_code',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const byTooth: Record<string, string> = {};
|
||||||
|
const selected = new Set(teeth);
|
||||||
|
if (defaultType) {
|
||||||
|
for (const tooth of teeth) byTooth[tooth] = defaultType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides: ProsthesisIntent['overrides'] = Array.isArray(
|
||||||
|
intent.overrides,
|
||||||
|
)
|
||||||
|
? intent.overrides
|
||||||
|
: [];
|
||||||
|
for (const override of overrides) {
|
||||||
|
const tooth = resolveToothIntent(override?.tooth);
|
||||||
|
const type = resolveCatalogCode(override?.type, allowed);
|
||||||
|
const spoken = spokenOf(override?.tooth) || String(override?.type ?? '');
|
||||||
|
if (!tooth) {
|
||||||
|
unresolved.push({ spoken, reason: 'malformed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A tooth we understood perfectly well but which is not part of this detail. Saying
|
||||||
|
// so is actionable ("add tooth 37, or drop it"); calling it malformed is not.
|
||||||
|
if (!selected.has(tooth)) {
|
||||||
|
unresolved.push({ spoken, reason: 'tooth_not_selected' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!type) {
|
||||||
|
unresolved.push({ spoken, reason: 'unknown_catalog_code' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byTooth[tooth] = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing usable was said about prosthesis. Returning an empty-but-present map would
|
||||||
|
// paint a plain restoration with a fabricated "incomplete, cannot ship" warning.
|
||||||
|
if (Object.keys(byTooth).length === 0) {
|
||||||
|
return { prosthesis: null, unresolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
|
||||||
|
return {
|
||||||
|
prosthesis: {
|
||||||
|
byTooth,
|
||||||
|
complete: missingTeeth.length === 0,
|
||||||
|
missingTeeth,
|
||||||
|
},
|
||||||
|
unresolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compose every resolver into the payload the review sheet renders. */
|
||||||
|
export function resolveVoiceIntent(
|
||||||
|
intent: VoiceIntent,
|
||||||
|
ctx: ResolveContext,
|
||||||
|
): ResolvedExtraction {
|
||||||
|
const unresolved: UnresolvedItem[] = [];
|
||||||
|
|
||||||
|
const toothResult = resolveToothIntents(intent?.teeth ?? []);
|
||||||
|
unresolved.push(...toothResult.unresolved);
|
||||||
|
|
||||||
|
const spanResult = resolveConnectedSpans(
|
||||||
|
intent?.connectedSpans ?? [],
|
||||||
|
toothResult.teeth,
|
||||||
|
);
|
||||||
|
unresolved.push(...spanResult.unresolved);
|
||||||
|
|
||||||
|
const treatmentType = resolveCatalogCode(
|
||||||
|
intent?.treatmentType,
|
||||||
|
ctx.treatmentTypeCodes,
|
||||||
|
);
|
||||||
|
if (intent?.treatmentType != null && !treatmentType) {
|
||||||
|
unresolved.push({
|
||||||
|
spoken: String(intent.treatmentType),
|
||||||
|
reason: 'unknown_catalog_code',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const prosthesisResult = resolveProsthesis(
|
||||||
|
intent?.prosthesis,
|
||||||
|
spanResult.teeth,
|
||||||
|
ctx.prosthesisTypeCodes,
|
||||||
|
);
|
||||||
|
unresolved.push(...prosthesisResult.unresolved);
|
||||||
|
|
||||||
|
const due = resolveDueDate(intent?.due, ctx.todayIso);
|
||||||
|
if (due.unresolved) unresolved.push(due.unresolved);
|
||||||
|
|
||||||
|
const comment =
|
||||||
|
typeof intent?.comment === 'string' && intent.comment.trim()
|
||||||
|
? intent.comment.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// A lab id the model invented is worse than none — it would ship a case to a lab the
|
||||||
|
// clinic never named. Only ids from the list we supplied survive, and a rejected one is
|
||||||
|
// reported: a hallucinated lab must not look identical to "no lab was spoken".
|
||||||
|
const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
|
||||||
|
if (intent?.labId != null && !labId) {
|
||||||
|
unresolved.push({
|
||||||
|
spoken: String(intent.labId),
|
||||||
|
reason: 'unknown_catalog_code',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
treatmentType,
|
||||||
|
teeth: spanResult.teeth,
|
||||||
|
toothSelectionGroups: spanResult.groups,
|
||||||
|
comment,
|
||||||
|
prosthesis: prosthesisResult.prosthesis,
|
||||||
|
labId,
|
||||||
|
labMatchExact: labId ? intent?.labMatchExact === true : false,
|
||||||
|
dueDate: due.dueDate,
|
||||||
|
unresolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ export type UnresolvedReason =
|
|||||||
| 'malformed'
|
| 'malformed'
|
||||||
| 'span_not_same_arch'
|
| 'span_not_same_arch'
|
||||||
| 'unknown_catalog_code'
|
| 'unknown_catalog_code'
|
||||||
|
| 'tooth_not_selected'
|
||||||
| 'invalid_date';
|
| 'invalid_date';
|
||||||
|
|
||||||
export type UnresolvedItem = {
|
export type UnresolvedItem = {
|
||||||
|
|||||||
Reference in New Issue
Block a user