feat(treatment): add voice detail entry #65

Merged
admin merged 32 commits from feat/voice-treatment-entry into master 2026-08-24 11:13:48 +03:30
7 changed files with 723 additions and 2 deletions
Showing only changes of commit ae7009534e - Show all commits

View File

@@ -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']);
});
});
});

View File

@@ -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));
}

View File

@@ -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.

View File

@@ -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));
}

View 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();
});
});

View 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,
};
}

View File

@@ -68,6 +68,7 @@ export type UnresolvedReason =
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export type UnresolvedItem = {