feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi';
|
|
|
|
|
import {
|
|
|
|
|
PROSTHESIS_TYPES,
|
|
|
|
|
PROSTHESIS_CATEGORY_LABELS,
|
|
|
|
|
PROSTHESIS_SUBCATEGORY_LABELS,
|
|
|
|
|
} from '../../../prisma/catalog-seed-data';
|
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,
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
resolveProsthesisAssignment,
|
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
|
|
|
resolveVoiceIntent,
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
type ProsthesisLeaf,
|
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
|
|
|
type ResolveContext,
|
|
|
|
|
} from './extraction.resolver';
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
import type {
|
|
|
|
|
ProsthesisAssignment,
|
|
|
|
|
ToothIntent,
|
|
|
|
|
UnresolvedItem,
|
|
|
|
|
VoiceIntent,
|
|
|
|
|
} from './voice.types';
|
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
|
|
|
|
|
|
|
|
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
|
|
|
|
kind: 'explicit',
|
|
|
|
|
fdi,
|
|
|
|
|
spoken,
|
|
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
const positional = (
|
|
|
|
|
overrides: Partial<Extract<ToothIntent, { kind: 'positional' }>> = {},
|
|
|
|
|
): ToothIntent => ({
|
|
|
|
|
kind: 'positional',
|
|
|
|
|
arch: undefined as never,
|
|
|
|
|
side: undefined as never,
|
|
|
|
|
position: Number.NaN,
|
|
|
|
|
spoken: '',
|
|
|
|
|
...overrides,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const PROSTHESIS_LEAVES: ProsthesisLeaf[] = [
|
|
|
|
|
{
|
|
|
|
|
code: 'pfm_crown',
|
|
|
|
|
category: 'crown',
|
|
|
|
|
subcategory: '',
|
|
|
|
|
chartRegion: 'crown',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
code: 'monolithic_zirconia',
|
|
|
|
|
category: 'crown',
|
|
|
|
|
subcategory: '',
|
|
|
|
|
chartRegion: 'crown',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
code: 'zirconia_abutment',
|
|
|
|
|
category: 'implant',
|
|
|
|
|
subcategory: '',
|
|
|
|
|
chartRegion: 'root',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
code: 'screw_retained',
|
|
|
|
|
category: 'implant',
|
|
|
|
|
subcategory: '',
|
|
|
|
|
chartRegion: 'crown',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
code: 'night_guard_soft',
|
|
|
|
|
category: 'appliance',
|
|
|
|
|
subcategory: 'night_guard',
|
|
|
|
|
chartRegion: 'arch',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
code: 'complete_denture',
|
|
|
|
|
category: 'removable',
|
|
|
|
|
subcategory: '',
|
|
|
|
|
chartRegion: 'arch',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
code: 'partial_denture',
|
|
|
|
|
category: 'removable',
|
|
|
|
|
subcategory: '',
|
|
|
|
|
chartRegion: 'crown',
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
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
|
|
|
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']),
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
prosthesisLeaves: PROSTHESIS_LEAVES,
|
|
|
|
|
prosthesisCategoryCodes: new Set([
|
|
|
|
|
'crown',
|
|
|
|
|
'implant',
|
|
|
|
|
'removable',
|
|
|
|
|
'appliance',
|
|
|
|
|
]),
|
|
|
|
|
prosthesisSubcategoryCodes: new Set(['night_guard']),
|
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
|
|
|
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',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
describe('resolveProsthesisAssignment', () => {
|
|
|
|
|
function resolve(assignment: ProsthesisAssignment, index = 0) {
|
|
|
|
|
const unresolved: UnresolvedItem[] = [];
|
|
|
|
|
const resolved = resolveProsthesisAssignment(
|
|
|
|
|
assignment,
|
|
|
|
|
index,
|
|
|
|
|
CTX,
|
|
|
|
|
unresolved,
|
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
|
|
|
);
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
return { resolved, unresolved };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('resolves explicit tooth targets to FDI codes', () => {
|
|
|
|
|
const { resolved } = resolve({
|
|
|
|
|
targets: [tooth('12'), tooth('13')],
|
|
|
|
|
types: ['pfm_crown'],
|
|
|
|
|
spoken: 'روکش پیافام برای ۱۲ و ۱۳',
|
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
|
|
|
});
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
expect(resolved.targets.sort()).toEqual(['12', '13']);
|
|
|
|
|
expect(resolved.types).toEqual(['pfm_crown']);
|
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
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
it('resolves a stack — more than one type on the same target', () => {
|
|
|
|
|
const { resolved } = resolve({
|
|
|
|
|
targets: [tooth('12')],
|
|
|
|
|
types: ['zirconia_abutment', 'monolithic_zirconia'],
|
|
|
|
|
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
|
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
|
|
|
});
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
expect(resolved.targets).toEqual(['12']);
|
|
|
|
|
expect(resolved.types.sort()).toEqual([
|
|
|
|
|
'monolithic_zirconia',
|
|
|
|
|
'zirconia_abutment',
|
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
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
it('resolves an arch target with no position to the jaw sentinel', () => {
|
|
|
|
|
const { resolved } = resolve({
|
|
|
|
|
targets: [positional({ arch: 'upper' })],
|
|
|
|
|
types: ['night_guard_soft'],
|
|
|
|
|
spoken: 'نایت گارد فک بالا',
|
|
|
|
|
});
|
|
|
|
|
expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
|
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
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
it('resolves "both jaws" to both sentinels', () => {
|
|
|
|
|
const { resolved } = resolve({
|
|
|
|
|
targets: [positional({ arch: 'both' })],
|
|
|
|
|
types: ['night_guard_soft'],
|
|
|
|
|
spoken: 'نایت گارد هر دو فک',
|
|
|
|
|
});
|
|
|
|
|
expect(resolved.targets.sort()).toEqual(
|
|
|
|
|
[ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER].sort(),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('classifies a leaf, a category and a subcategory code independently', () => {
|
|
|
|
|
const leaf = resolve({
|
|
|
|
|
targets: [tooth('12')],
|
|
|
|
|
types: ['pfm_crown'],
|
|
|
|
|
spoken: '',
|
|
|
|
|
});
|
|
|
|
|
expect(leaf.resolved.types).toEqual(['pfm_crown']);
|
|
|
|
|
expect(leaf.unresolved).toEqual([]);
|
|
|
|
|
|
|
|
|
|
const category = resolve({
|
|
|
|
|
targets: [tooth('12')],
|
|
|
|
|
types: ['crown'],
|
|
|
|
|
spoken: 'روکش',
|
|
|
|
|
});
|
|
|
|
|
expect(category.resolved.types).toEqual([]);
|
|
|
|
|
expect(category.unresolved).toEqual([
|
|
|
|
|
{
|
|
|
|
|
spoken: 'روکش',
|
|
|
|
|
reason: 'prosthesis_type_ambiguous',
|
|
|
|
|
candidates: ['monolithic_zirconia', 'pfm_crown'],
|
|
|
|
|
assignmentIndex: 0,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const subcategory = resolve({
|
|
|
|
|
targets: [positional({ arch: 'upper' })],
|
|
|
|
|
types: ['night_guard'],
|
|
|
|
|
spoken: 'نایت گارد',
|
|
|
|
|
});
|
|
|
|
|
expect(subcategory.resolved.types).toEqual([]);
|
|
|
|
|
expect(subcategory.unresolved[0]).toMatchObject({
|
|
|
|
|
reason: 'prosthesis_type_ambiguous',
|
|
|
|
|
candidates: ['night_guard_soft'],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('asserts the leaf, category and subcategory namespaces are disjoint on the live catalog', () => {
|
|
|
|
|
const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code));
|
|
|
|
|
const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category));
|
|
|
|
|
const subcategoryCodes = new Set(
|
|
|
|
|
PROSTHESIS_TYPES.map((t) => t.subcategory).filter(Boolean),
|
|
|
|
|
);
|
|
|
|
|
for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false);
|
|
|
|
|
for (const code of subcategoryCodes) {
|
|
|
|
|
expect(leafCodes.has(code)).toBe(false);
|
|
|
|
|
expect(categoryCodes.has(code)).toBe(false);
|
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
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
it('reports a code not in the supplied catalog', () => {
|
|
|
|
|
const { resolved, unresolved } = resolve({
|
|
|
|
|
targets: [tooth('12')],
|
|
|
|
|
types: ['gold_foil'],
|
|
|
|
|
spoken: '',
|
|
|
|
|
});
|
|
|
|
|
expect(resolved.types).toEqual([]);
|
|
|
|
|
expect(unresolved).toEqual([
|
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
|
|
|
{
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
spoken: 'gold_foil',
|
|
|
|
|
reason: 'unknown_catalog_code',
|
|
|
|
|
assignmentIndex: 0,
|
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
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
it('rejects an arch code aimed at a tooth', () => {
|
|
|
|
|
const { resolved, unresolved } = resolve({
|
|
|
|
|
targets: [tooth('12')],
|
|
|
|
|
types: ['night_guard_soft'],
|
|
|
|
|
spoken: 'دندون ۱۲ نایت گارد',
|
|
|
|
|
});
|
|
|
|
|
expect(resolved.targets).toEqual([]);
|
|
|
|
|
expect(unresolved).toEqual([
|
|
|
|
|
{ spoken: '12', reason: 'code_not_valid_for_target', assignmentIndex: 0 },
|
|
|
|
|
]);
|
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
|
|
|
});
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
|
|
|
|
|
it('rejects a tooth code aimed at a jaw', () => {
|
|
|
|
|
const { resolved, unresolved } = resolve({
|
|
|
|
|
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
|
|
|
|
|
types: ['pfm_crown'],
|
|
|
|
|
spoken: 'فک بالا',
|
|
|
|
|
});
|
|
|
|
|
expect(resolved.targets).toEqual([]);
|
|
|
|
|
expect(unresolved).toEqual([
|
|
|
|
|
{
|
|
|
|
|
spoken: 'فک بالا',
|
|
|
|
|
reason: 'code_not_valid_for_target',
|
|
|
|
|
assignmentIndex: 0,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('defers the region check for a mixed-region category (removable)', () => {
|
|
|
|
|
// complete_denture is 'arch', partial_denture is 'crown' — no region to check until a
|
|
|
|
|
// leaf is picked. Neither a tooth nor a jaw target may be rejected while it is still
|
|
|
|
|
// the bare category.
|
|
|
|
|
const toothTarget = resolve({
|
|
|
|
|
targets: [tooth('12')],
|
|
|
|
|
types: ['removable'],
|
|
|
|
|
spoken: 'دنچر برای ۱۲',
|
|
|
|
|
});
|
|
|
|
|
expect(toothTarget.resolved.targets).toEqual(['12']);
|
|
|
|
|
expect(toothTarget.unresolved).toContainEqual(
|
|
|
|
|
expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }),
|
|
|
|
|
);
|
|
|
|
|
expect(toothTarget.unresolved).not.toContainEqual(
|
|
|
|
|
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const jawTarget = resolve({
|
|
|
|
|
targets: [positional({ arch: 'upper' })],
|
|
|
|
|
types: ['removable'],
|
|
|
|
|
spoken: 'دنچر فک بالا',
|
|
|
|
|
});
|
|
|
|
|
expect(jawTarget.resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
|
|
|
|
|
expect(jawTarget.unresolved).not.toContainEqual(
|
|
|
|
|
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('a target with no types resolves with an empty types[], and does not fail the assignment', () => {
|
|
|
|
|
const { resolved } = resolve({
|
|
|
|
|
targets: [tooth('13'), tooth('14')],
|
|
|
|
|
types: [],
|
|
|
|
|
spoken: '۱۳ و ۱۴',
|
|
|
|
|
});
|
|
|
|
|
// Nothing to validate a region against yet, so a bare target still resolves as valid
|
|
|
|
|
// geometry — the frontend renders it struck through ("no prosthesis heard") because
|
|
|
|
|
// `types` is empty and no `prosthesis_type_ambiguous` item references this assignment.
|
|
|
|
|
expect(resolved.targets.sort()).toEqual(['13', '14']);
|
|
|
|
|
expect(resolved.types).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('one target failing validity does not exclude the rest of the assignment', () => {
|
|
|
|
|
const { resolved, unresolved } = resolve({
|
|
|
|
|
targets: [tooth('12'), positional({ arch: 'upper' })],
|
|
|
|
|
types: ['pfm_crown'],
|
|
|
|
|
spoken: '',
|
|
|
|
|
});
|
|
|
|
|
expect(resolved.targets).toEqual(['12']);
|
|
|
|
|
expect(unresolved).toContainEqual(
|
|
|
|
|
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('carries an assignment target that could not be resolved, with the arch candidates', () => {
|
|
|
|
|
const { unresolved } = resolve({
|
|
|
|
|
targets: [positional({ spoken: 'نایت گارد' })],
|
|
|
|
|
types: ['night_guard_soft'],
|
|
|
|
|
spoken: 'نایت گارد',
|
|
|
|
|
});
|
|
|
|
|
expect(unresolved).toContainEqual({
|
|
|
|
|
spoken: 'نایت گارد',
|
|
|
|
|
reason: 'arch_not_spoken',
|
|
|
|
|
candidates: ['upper', 'lower'],
|
|
|
|
|
assignmentIndex: 0,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('carries the assignment index on a missing-quadrant target', () => {
|
|
|
|
|
const { unresolved } = resolve({
|
|
|
|
|
targets: [positional({ position: 2, spoken: 'دندون دو' })],
|
|
|
|
|
types: ['pfm_crown'],
|
|
|
|
|
spoken: 'دندون دو روکش',
|
|
|
|
|
});
|
|
|
|
|
expect(unresolved).toContainEqual(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
reason: 'tooth_missing_quadrant',
|
|
|
|
|
assignmentIndex: 0,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('every prosthesis category and subcategory has a non-empty label in fa/en/nl', () => {
|
|
|
|
|
const locales = ['fa', 'en', 'nl'] as const;
|
|
|
|
|
|
|
|
|
|
it.each(Object.entries(PROSTHESIS_CATEGORY_LABELS))(
|
|
|
|
|
'category %s',
|
|
|
|
|
(_code, labels) => {
|
|
|
|
|
for (const locale of locales) {
|
|
|
|
|
expect(labels[locale]?.trim()).toBeTruthy();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
it.each(Object.entries(PROSTHESIS_SUBCATEGORY_LABELS))(
|
|
|
|
|
'subcategory %s',
|
|
|
|
|
(_code, labels) => {
|
|
|
|
|
for (const locale of locales) {
|
|
|
|
|
expect(labels[locale]?.trim()).toBeTruthy();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('resolveVoiceIntent', () => {
|
|
|
|
|
const base: VoiceIntent = {
|
|
|
|
|
treatmentType: 'restoration',
|
|
|
|
|
teeth: [tooth('14'), tooth('15')],
|
|
|
|
|
connectedSpans: [],
|
|
|
|
|
comment: ' حساسیت به سرما ',
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
prosthesis: [],
|
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
|
|
|
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('حساسیت به سرما');
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
expect(result.prosthesisAssignments).toEqual([]);
|
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
|
|
|
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);
|
feat: wire voice entry into the treatment workspace
Makes the feature reachable end to end: availability is fetched alongside the
catalogs, the capture hook drives the segmented control, and confirming the
review sheet appends a new detail.
Confirm always appends — it never edits an existing detail and never calls
onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the
type row leaves the appointment-purpose default rather than a blank. Lab-side
rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new
unsaved detail can carry a lab, due date and per-tooth prosthesis map.
Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are
baked in at build time; a failure fetching it degrades to no microphone rather
than taking the treatment tab down.
From review of this commit:
- Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows
for teeth the detail does not contain. Nothing downstream filters them —
assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the
reverse — so they would have reached task generation as lab work for teeth
nobody is treating. The map is now filtered to the detail's own teeth.
- The microphone was gated on the URL locale while the server resolved
everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a
language toggle whose save failed), which would transcribe Persian with an
English hint and anchor "next Thursday" to a Monday week instead of a Saturday
one — or 403 from a visibly-enabled button. The client now sends the locale the
microphone was offered in, so the gate and the request agree by construction.
Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day
early west of Greenwich (parsed as UTC midnight); the missing-teeth list
hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so
the common single-field case read "Apply 1 fields".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 20:22:42 +03:30
|
|
|
// The id is not what the clinician said — quoting it back shows them a raw UUID.
|
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
|
|
|
expect(result.unresolved).toContainEqual({
|
feat: wire voice entry into the treatment workspace
Makes the feature reachable end to end: availability is fetched alongside the
catalogs, the capture hook drives the segmented control, and confirming the
review sheet appends a new detail.
Confirm always appends — it never edits an existing detail and never calls
onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the
type row leaves the appointment-purpose default rather than a blank. Lab-side
rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new
unsaved detail can carry a lab, due date and per-tooth prosthesis map.
Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are
baked in at build time; a failure fetching it degrades to no microphone rather
than taking the treatment tab down.
From review of this commit:
- Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows
for teeth the detail does not contain. Nothing downstream filters them —
assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the
reverse — so they would have reached task generation as lab work for teeth
nobody is treating. The map is now filtered to the detail's own teeth.
- The microphone was gated on the URL locale while the server resolved
everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a
language toggle whose save failed), which would transcribe Persian with an
English hint and anchor "next Thursday" to a Monday week instead of a Saturday
one — or 403 from a visibly-enabled button. The client now sends the locale the
microphone was offered in, so the gate and the request agree by construction.
Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day
early west of Greenwich (parsed as UTC midnight); the missing-teeth list
hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so
the common single-field case read "Apply 1 fields".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 20:22:42 +03:30
|
|
|
spoken: '',
|
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
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
it('forces treatmentType to prosthesis when an assignment resolves', () => {
|
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
|
|
|
const result = resolveVoiceIntent(
|
|
|
|
|
{
|
|
|
|
|
...base,
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
treatmentType: 'restoration',
|
|
|
|
|
prosthesis: [
|
|
|
|
|
{
|
|
|
|
|
targets: [tooth('12')],
|
|
|
|
|
types: ['pfm_crown'],
|
|
|
|
|
spoken: 'روکش برای ۱۲',
|
|
|
|
|
},
|
|
|
|
|
],
|
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
|
|
|
},
|
|
|
|
|
CTX,
|
|
|
|
|
);
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
expect(result.treatmentType).toBe('prosthesis');
|
|
|
|
|
expect(result.prosthesisAssignments).toEqual([
|
|
|
|
|
{ targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' },
|
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
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
it('does not force prosthesis when every assignment resolved no target', () => {
|
|
|
|
|
const result = resolveVoiceIntent(
|
|
|
|
|
{
|
|
|
|
|
...base,
|
|
|
|
|
prosthesis: [{ targets: [], types: ['pfm_crown'], spoken: '' }],
|
|
|
|
|
},
|
|
|
|
|
CTX,
|
|
|
|
|
);
|
|
|
|
|
expect(result.treatmentType).toBe('restoration');
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
|
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
|
|
|
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 },
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
prosthesis: [
|
|
|
|
|
{
|
|
|
|
|
targets: [tooth('99', 'نود و نه')],
|
|
|
|
|
types: ['pfm_crown'],
|
|
|
|
|
spoken: '',
|
|
|
|
|
},
|
|
|
|
|
],
|
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
|
|
|
},
|
|
|
|
|
CTX,
|
|
|
|
|
);
|
|
|
|
|
const reasons = result.unresolved.map((u) => u.reason).sort();
|
|
|
|
|
expect(reasons).toEqual([
|
|
|
|
|
'invalid_date',
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
'malformed',
|
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
|
|
|
'not_permanent_tooth',
|
|
|
|
|
'span_not_same_arch',
|
|
|
|
|
'unknown_catalog_code',
|
|
|
|
|
]);
|
feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the
fixes that follow are reviewable against it.
Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire
shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a
tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode /
resolveProsthesisAssignment for leaf-vs-category classification, region
validity with mixed-region deferral, and assignmentIndex on unresolved
items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to
CatalogEntityKind with a migration and seeded fa/en/nl translations; and
rewrites the extraction prompt to render the catalog as a tree.
Frontend: merged "teeth and prosthesis" row, stack preview through the
existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult
and voiceForEditor, and the two carried-forward recording fixes — the
container fallback that refused Safari and the render gate that never
checked isMediaRecorderSupported().
Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md.
Gate was green: backend 16 suites / 209 tests, nest build, prisma validate;
frontend 37 Vitest tests, tsc --noEmit, next build.
KNOWN DEFECTS, fixed in the commits that follow:
- VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply
- VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's
type-row lock is missing, so unticking it saves prosthesis lab rows on a
non-prosthesis detail
Reviewed on the correctness lens only; regression-risk never ran. The
migration was validated but never applied.
Spec: docs/specs/voice-treatment-entry/spec.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:23:58 +08:00
|
|
|
|
|
|
|
|
const outsideAssignment = result.unresolved.filter(
|
|
|
|
|
(u) => u.reason === 'not_permanent_tooth',
|
|
|
|
|
);
|
|
|
|
|
expect(outsideAssignment[0].assignmentIndex).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
const insideAssignment = result.unresolved.filter(
|
|
|
|
|
(u) => u.reason === 'malformed',
|
|
|
|
|
);
|
|
|
|
|
expect(insideAssignment[0].assignmentIndex).toBe(0);
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('treats an empty comment as absent', () => {
|
|
|
|
|
expect(
|
|
|
|
|
resolveVoiceIntent({ ...base, comment: ' ' }, CTX).comment,
|
|
|
|
|
).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|