feat(treatment): add voice detail entry #65
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
areArchNeighbors,
|
||||
sortInArchOrder,
|
||||
FDI_TOOTH_IDS,
|
||||
isFdiTooth,
|
||||
sameArch,
|
||||
@@ -122,4 +123,22 @@ describe('FDI geometry', () => {
|
||||
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}`;
|
||||
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', () => {
|
||||
// A bare string is a deadline we failed to parse, not an absent one — the clinician
|
||||
// must see that something was heard and lost.
|
||||
|
||||
@@ -87,8 +87,12 @@ function describe(intent: DueIntent): string {
|
||||
return `${intent.jy}/${intent.jm}/${intent.jd}`;
|
||||
case 'gregorian':
|
||||
return `${intent.y}-${intent.m}-${intent.d}`;
|
||||
default:
|
||||
return '';
|
||||
default: {
|
||||
// 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') {
|
||||
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)) {
|
||||
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'
|
||||
| 'span_not_same_arch'
|
||||
| 'unknown_catalog_code'
|
||||
| 'tooth_not_selected'
|
||||
| 'invalid_date';
|
||||
|
||||
export type UnresolvedItem = {
|
||||
|
||||
Reference in New Issue
Block a user