306 lines
9.1 KiB
TypeScript
306 lines
9.1 KiB
TypeScript
|
|
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,
|
||
|
|
};
|
||
|
|
}
|