feat(treatment): add voice detail entry #65
@@ -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