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>
2026-08-20 17:41:41 +03:30
|
|
|
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',
|
feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.
The two halves share a wrapper and nothing else. Add keeps its exact behaviour.
The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.
Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.
Fixes from review of this commit:
- mountedRef was set false on unmount and never re-armed, so under StrictMode
the hook was permanently "unmounted" in dev and recording silently never
started.
- onStart guarded only on `phase`, which does not change until getUserMedia
resolves; a second click during the permission prompt orphaned the first
MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
a bare weekday carries no qualifier, and rejecting it discarded a real
deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
Safari's mp4/aac clips are not mislabelled.
Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
|
|
|
weekStartJs: 6, // Saturday — the fa week
|
|
|
|
|
|
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>
2026-08-20 17:41:41 +03:30
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|