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>
This commit is contained in:
2026-09-07 12:23:58 +08:00
parent 77e2ed4b42
commit 15ddb9aac2
29 changed files with 3630 additions and 524 deletions

View File

@@ -39,6 +39,7 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or
| `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
| `npm run build` | Production build (`output: 'standalone'`) |
| `npm run lint` | ESLint via Next |
| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`) |
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
@@ -102,7 +103,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
### Tests
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation (7 suites in `backend/src/**`). There are no frontend tests; `npx tsc --noEmit` is the frontend gate.
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts` and `voiceReviewRows.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. `npx tsc --noEmit` remains the frontend's cross-cutting gate.
## Deployment

View File

@@ -71,7 +71,11 @@ export const LAB_WORKFLOW_STEPS = [
] as const;
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
export type ProsthesisStackGroup =
| 'restoration'
| 'implant'
| 'post_core'
| 'arch';
export type ProsthesisTypeSeed = {
code: string;
@@ -87,7 +91,13 @@ export type ProsthesisTypeSeed = {
};
const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const;
const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const;
const INDIRECT_FULL = [
...SCAN_DESIGN_MODEL,
'milling_wet',
'sinter',
'stain',
'glaze',
] as const;
const INDIRECT_LAYERED = [
...SCAN_DESIGN_MODEL,
'milling_wet',
@@ -115,9 +125,24 @@ const KEEP_LAYERED = [
'glaze',
'polish_prep',
] as const;
const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const;
const DENTURE = [
'intraoral_scan',
'design',
'printer_resin',
'polish_prep',
] as const;
const APPLIANCE = [
'intraoral_scan',
'design',
'printer_resin',
'polish_prep',
] as const;
const POST_CORE = [
'intraoral_scan',
'design',
'milling_wet',
'polish_prep',
] as const;
function crown(
code: string,
@@ -165,7 +190,8 @@ function indirect(
subcategory: indication,
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
manufacturingSteps:
technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
};
}
@@ -200,10 +226,24 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'stain',
'glaze',
]),
crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']),
crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']),
crown('full_metal_crown', 5, [
...SCAN_DESIGN_MODEL,
'milling_wet',
'polish_prep',
]),
crown('temporary_resin_crown', 6, [
'intraoral_scan',
'design',
'milling_wet',
'polish_prep',
]),
crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('peek_crown', 8, [
'intraoral_scan',
'design',
'milling_dry',
'polish_prep',
]),
crown('press_ceramic', 9, [
'intraoral_scan',
'design',
@@ -243,7 +283,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'model_create',
'polish_prep',
]),
implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
implant('ti_base_abutment', 41, [
'intraoral_scan',
'model_create',
'polish_prep',
]),
implant('multi_unit_abutment', 42, [
'intraoral_scan',
'choosing_abutment',
@@ -255,7 +299,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'milling_wet',
'polish_prep',
]),
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
implant('zirconia_abutment', 44, [
...SCAN_DESIGN_MODEL,
'milling_dry',
'sinter',
]),
implant(
'screw_retained',
45,
@@ -450,7 +498,11 @@ export {
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
specialized_restoration: {
en: 'Specialized Restoration',
fa: 'ترمیم تخصصی',
nl: 'Gespecialiseerde Restauratie',
},
radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
@@ -460,8 +512,16 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' },
continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' },
clinic_visit: {
en: 'Clinic Visit',
fa: 'ویزیت درمانگاه',
nl: 'Kliniekbezoek',
},
continue_treatment: {
en: 'Continue Treatment',
fa: 'ادامه درمان',
nl: 'Behandeling Voortzetten',
},
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
@@ -471,50 +531,182 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشه‌ای', nl: 'Glaskeramische Kroon' },
full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' },
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
monolithic_zirconia: {
en: 'Monolithic Zirconia',
fa: 'زیرکونیا مونولیتیک',
nl: 'Monolithisch Zirconia',
},
glass_ceramic_crown: {
en: 'Glass Ceramic Crown',
fa: 'روکش سرامیک شیشه‌ای',
nl: 'Glaskeramische Kroon',
},
full_metal_crown: {
en: 'Full Metal Crown',
fa: 'روکش تمام فلز',
nl: 'Volledige Metalen Kroon',
},
temporary_resin_crown: {
en: 'Temporary Resin Crown',
fa: 'روکش موقت رزینی',
nl: 'Tijdelijke Harskroon',
},
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' },
veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' },
veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' },
inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' },
inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' },
onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' },
onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' },
overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' },
overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' },
cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' },
fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' },
customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' },
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' },
press_ceramic: {
en: 'Pressed Ceramic / IPS e.max',
fa: 'سرامیک پرس / IPS e.max',
nl: 'Perskeramiek / IPS e.max',
},
veneer_full_contour: {
en: 'Veneer · Full contour',
fa: 'ونیر · تمام‌کانتور',
nl: 'Veneer · Full contour',
},
veneer_layered: {
en: 'Veneer · Layered ceramic',
fa: 'ونیر · سرامیک لایه‌ای',
nl: 'Veneer · Gelaagd keramiek',
},
inlay_full_contour: {
en: 'Inlay · Full contour',
fa: 'اینلی · تمام‌کانتور',
nl: 'Inlay · Full contour',
},
inlay_layered: {
en: 'Inlay · Layered ceramic',
fa: 'اینلی · سرامیک لایه‌ای',
nl: 'Inlay · Gelaagd keramiek',
},
onlay_full_contour: {
en: 'Onlay · Full contour',
fa: 'آنلی · تمام‌کانتور',
nl: 'Onlay · Full contour',
},
onlay_layered: {
en: 'Onlay · Layered ceramic',
fa: 'آنلی · سرامیک لایه‌ای',
nl: 'Onlay · Gelaagd keramiek',
},
overlay_full_contour: {
en: 'Overlay · Full contour',
fa: 'اورلی · تمام‌کانتور',
nl: 'Overlay · Full contour',
},
overlay_layered: {
en: 'Overlay · Layered ceramic',
fa: 'اورلی · سرامیک لایه‌ای',
nl: 'Overlay · Gelaagd keramiek',
},
cast_post_core: {
en: 'Cast Post & Core',
fa: 'پست و کور ریختگی',
nl: 'Gegoten Stiftopbouw',
},
fiber_post_core: {
en: 'Fiber Post & Core',
fa: 'پست و کور فایبر',
nl: 'Fiber Stiftopbouw',
},
customized_abutment: {
en: 'Custom Abutment (Titanium)',
fa: 'اباتمنت سفارشی (تیتانیوم)',
nl: 'Aangepast abutment (titanium)',
},
prefabricated_abutment: {
en: 'Prefabricated Abutment',
fa: 'اباتمنت آماده',
nl: 'Prefab Abutment',
},
ti_base_abutment: {
en: 'Ti Base Abutment',
fa: 'اباتمنت پایه تیتانیوم',
nl: 'Ti Basis Abutment',
},
multi_unit_abutment: {
en: 'Multi Unit Abutment',
fa: 'اباتمنت مولتی یونیت',
nl: 'Multi Unit Abutment',
},
zirconia_abutment: {
en: 'Custom Abutment (Zirconia)',
fa: 'اباتمنت سفارشی (زیرکونیا)',
nl: 'Aangepast abutment (zirconia)',
},
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' },
partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' },
complete_denture: {
en: 'Complete Denture',
fa: 'دنچر کامل',
nl: 'Volledige prothese',
},
partial_denture: {
en: 'Partial Denture',
fa: 'دنچر پارسیل',
nl: 'Partiële prothese',
},
overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' },
night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' },
night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' },
night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' },
night_guard_soft: {
en: 'Night Guard · Soft',
fa: 'نایت گارد · نرم',
nl: 'Nachtbeugel · Zacht',
},
night_guard_hard: {
en: 'Night Guard · Hard',
fa: 'نایت گارد · سخت',
nl: 'Nachtbeugel · Hard',
},
night_guard_dual: {
en: 'Night Guard · Dual laminate',
fa: 'نایت گارد · دو لایه',
nl: 'Nachtbeugel · Dual laminate',
},
bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' },
clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' },
clear_aligner: {
en: 'Clear Aligner',
fa: 'الاینر شفاف',
nl: 'Clear aligner',
},
soft_structure: {
en: 'Soft Structure',
fa: 'ساختار نرم',
nl: 'Zachte Structuur',
},
surgical_guide: {
en: 'Surgical Guide',
fa: 'گاید جراحی',
nl: 'Chirurgische mal',
},
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
veneer_zirconia: {
en: 'Veneer Zirconia',
fa: 'ونیر زیرکونیا',
nl: 'Veneer Zirconia',
},
veneer_ips_press: {
en: 'Veneer IPS Press',
fa: 'ونیر IPS پرس',
nl: 'Veneer IPS Press',
},
veneer_ips_cad: {
en: 'Veneer IPS CAD',
fa: 'ونیر IPS CAD',
nl: 'Veneer IPS CAD',
},
zirconia_overlay: {
en: 'Zirconia Overlay',
fa: 'اورلی زیرکونیا',
nl: 'Zirconia Overlay',
},
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
};
export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
intraoral_scan: {
en: 'Intraoral Scan',
fa: 'اسکن داخل دهان',
nl: 'Intraorale Scan',
},
choosing_abutment: {
en: 'Choosing Abutment',
fa: 'انتخاب اباتمنت',
@@ -530,7 +722,11 @@ export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
stain: { en: 'Stain', fa: 'رنگ‌آمیزی', nl: 'Kleuren' },
glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' },
polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آماده‌سازی', nl: 'Polijsten/Voorbereiding' },
polish_prep: {
en: 'Polish/Prep',
fa: 'پولیش/آماده‌سازی',
nl: 'Polijsten/Voorbereiding',
},
packing: { en: 'Packing', fa: 'بسته‌بندی', nl: 'Verpakken' },
shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
@@ -549,8 +745,58 @@ function labelsToTranslations(
return out;
}
/**
* The 7 prosthesis categories, worded from `frontend/messages/*.json`'s `category_*` keys
* (decision 47) so the extraction prompt and the manual picker read identically on day one.
* The frontend keeps its own message keys — migrating it to read these is out of scope
* (decision 48) — this seed is written *from* those keys precisely so the two agree at the
* point they diverge.
*/
export const PROSTHESIS_CATEGORY_LABELS: Record<
string,
Record<string, string>
> = {
crown: { en: 'Crowns', fa: 'روکش‌ها', nl: 'Kronen' },
indirect: {
en: 'Veneer/Inlay/Onlay/Overlay',
fa: 'ونیر/اینلی/آنلی/اورلی',
nl: 'Veneer/Inlay/Onlay/Overlay',
},
implant: { en: 'Implants', fa: 'ایمپلنت', nl: 'Implantaten' },
post_core: { en: 'Post & core', fa: 'پست و کور', nl: 'Stiftopbouw' },
removable: { en: 'Removable', fa: 'متحرک', nl: 'Uitneembaar' },
appliance: { en: 'Appliances', fa: 'اپلاینس‌ها', nl: 'Apparatuur' },
digital: { en: 'Digital', fa: 'دیجیتال', nl: 'Digitaal' },
};
/**
* Only the 5 real subcategories — the `sub_*` message key set is wider (it also carries
* technique names and two leaf codes); those are not subcategory catalog entities.
*/
export const PROSTHESIS_SUBCATEGORY_LABELS: Record<
string,
Record<string, string>
> = {
veneer: { en: 'Veneer', fa: 'ونیر', nl: 'Veneer' },
inlay: { en: 'Inlay', fa: 'اینلی', nl: 'Inlay' },
onlay: { en: 'Onlay', fa: 'آنلی', nl: 'Onlay' },
overlay: { en: 'Overlay', fa: 'اورلی', nl: 'Overlay' },
night_guard: { en: 'Night guard', fa: 'نایت گارد', nl: 'Nachtbeugel' },
};
export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS),
...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS),
...labelsToTranslations(
CatalogEntityKind.LAB_WORKFLOW_STEP,
WORKFLOW_STEP_LABELS,
),
...labelsToTranslations(
CatalogEntityKind.PROSTHESIS_CATEGORY,
PROSTHESIS_CATEGORY_LABELS,
),
...labelsToTranslations(
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
PROSTHESIS_SUBCATEGORY_LABELS,
),
];

View File

@@ -0,0 +1,6 @@
-- AlterEnum
-- Category and subcategory labels have no backend source today (spec §5, decision 47):
-- CatalogEntityKind covered only TREATMENT_TYPE / PROSTHESIS_TYPE / LAB_WORKFLOW_STEP, so
-- CatalogLabelService could not resolve a category at all. Additive only, no data loss.
ALTER TYPE "CatalogEntityKind" ADD VALUE 'PROSTHESIS_CATEGORY';
ALTER TYPE "CatalogEntityKind" ADD VALUE 'PROSTHESIS_SUBCATEGORY';

View File

@@ -332,6 +332,8 @@ enum CatalogEntityKind {
TREATMENT_TYPE
PROSTHESIS_TYPE
LAB_WORKFLOW_STEP
PROSTHESIS_CATEGORY
PROSTHESIS_SUBCATEGORY
}
model CatalogTranslation {

View File

@@ -62,6 +62,14 @@ export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
...FDI_LOWER_ARCH_ORDER,
]);
/**
* Not FDI codes — jaw-level prosthesis targets on `LabCaseToothProsthesis.tooth`. Mirrors
* `frontend/src/components/treatment/prosthesisTree.ts`'s `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER`
* so the voice contract speaks the same sentinel the manual chart already writes.
*/
export const ARCH_TOOTH_UPPER = 'UA';
export const ARCH_TOOTH_LOWER = 'LA';
export function isFdiTooth(value: unknown): value is string {
return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
}

View File

@@ -69,7 +69,9 @@ export class ProsthesisCatalogService implements OnModuleInit {
this.loaded = true;
}
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
async list(
localeInput?: string | null,
): Promise<ProsthesisTypeCatalogEntry[]> {
await this.refresh();
const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
@@ -92,17 +94,64 @@ export class ProsthesisCatalogService implements OnModuleInit {
stackGroup: row.stackGroup,
};
})
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
.sort(
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
);
}
/** The 7 distinct category codes present in the active catalog, with labels in `locale`. */
async listCategories(
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
return this.listDistinct(
(row) => row.category,
CatalogEntityKind.PROSTHESIS_CATEGORY,
localeInput,
);
}
/** The 5 distinct subcategory codes (veneer, inlay, onlay, overlay, night_guard, …). */
async listSubcategories(
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
return this.listDistinct(
(row) => row.subcategory,
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
localeInput,
);
}
private async listDistinct(
pick: (row: CatalogRow) => string,
entityKind: CatalogEntityKind,
localeInput?: string | null,
): Promise<{ code: string; label: string }[]> {
await this.refresh();
const locale = normalizeCatalogLocale(localeInput);
const codes = [
...new Set([...this.byCode.values()].map(pick).filter(Boolean)),
].sort();
const labels = await this.catalogLabels.resolveLabels(
entityKind,
codes,
locale,
);
return codes.map((code) => ({ code, label: labels.get(code) ?? code }));
}
assertKnownProsthesisType(code: string): void {
this.ensureLoaded();
if (!this.byCode.has(code)) {
throw new AppException(ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, HttpStatus.BAD_REQUEST);
throw new AppException(
ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE,
HttpStatus.BAD_REQUEST,
);
}
}
async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise<string[]> {
async getStepCodesForProsthesisType(
prosthesisTypeCode: string,
): Promise<string[]> {
const type = await this.prisma.prosthesisType.findUnique({
where: { code: prosthesisTypeCode },
select: {
@@ -120,7 +169,10 @@ export class ProsthesisCatalogService implements OnModuleInit {
return type.steps.map((s) => s.labWorkflowStep.code);
}
async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise<Map<string, string>> {
async resolveStepLabels(
stepCodes: string[],
locale: CatalogLocale,
): Promise<Map<string, string>> {
return this.catalogLabels.resolveLabels(
CatalogEntityKind.LAB_WORKFLOW_STEP,
stepCodes,

View File

@@ -8,14 +8,18 @@ const LOCALE_NOTES: Record<string, string> = {
'("دندون شماره ۲۶"). Digits may arrive in Persian or Latin script — either way, copy',
'the number into "fdi" as two Latin digits. The descriptive form is quadrant-relative:',
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just',
'"بالا"/"پایین" in context, or "هر دو فک" (both jaws).',
].join(' '),
nl: [
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six. A jaw is',
'"bovenkaak" (upper) or "onderkaak" (lower), or "beide kaken" (both).',
].join(' '),
en: [
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
'all tooth 26. The descriptive form is "upper right six".',
'all tooth 26. The descriptive form is "upper right six". A jaw is "upper jaw"/"lower',
'jaw", or "both jaws".',
].join(' '),
};
@@ -24,6 +28,63 @@ function codeList(entries: { code: string; label: string }[]): string {
return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n');
}
/** "per-tooth" | "jaw" | "per-tooth or jaw" — derived from the leaves' own chartRegion, never hardcoded. */
function regionNote(regions: ReadonlySet<string>): string {
const isJaw = regions.has('arch');
const isTooth = regions.has('crown') || regions.has('root');
if (isJaw && isTooth)
return 'per-tooth or jaw, depending on the specific code';
return isJaw ? 'jaw' : 'per-tooth';
}
/**
* Renders the prosthesis catalog as the tree it is: category -> (subcategory ->) leaves.
* Nothing here is hardcoded — every code, label and region annotation comes from the data
* `buildCatalog` fetched, so a catalog change needs no prompt change.
*/
function prosthesisTree(catalog: ExtractionCatalog): string {
const byCategory = new Map<string, ExtractionCatalog['prosthesisTypes']>();
for (const leaf of catalog.prosthesisTypes) {
const list = byCategory.get(leaf.category) ?? [];
list.push(leaf);
byCategory.set(leaf.category, list);
}
const lines: string[] = [];
for (const category of catalog.prosthesisCategories) {
const leaves = byCategory.get(category.code) ?? [];
const regions = new Set(leaves.map((l) => l.chartRegion));
lines.push(
`CATEGORY ${category.code} = ${category.label} (${regionNote(regions)})`,
);
const bySubcategory = new Map<string, typeof leaves>();
const bare: typeof leaves = [];
for (const leaf of leaves) {
if (leaf.subcategory) {
const list = bySubcategory.get(leaf.subcategory) ?? [];
list.push(leaf);
bySubcategory.set(leaf.subcategory, list);
} else {
bare.push(leaf);
}
}
for (const leaf of bare) {
lines.push(` - ${leaf.code} = ${leaf.label}`);
}
for (const sub of catalog.prosthesisSubcategories) {
const subLeaves = bySubcategory.get(sub.code);
if (!subLeaves || subLeaves.length === 0) continue;
lines.push(` SUBCATEGORY ${sub.code} = ${sub.label}`);
for (const leaf of subLeaves) {
lines.push(` - ${leaf.code} = ${leaf.label}`);
}
}
}
return lines.join('\n');
}
export function buildExtractionPrompt(
transcript: string,
catalog: ExtractionCatalog,
@@ -36,15 +97,15 @@ export function buildExtractionPrompt(
'You are a parser, not an assistant: report only what was said.',
'',
'HARD RULES',
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
' must be codes from the lists below. labId must be an id from the lab list. If what you',
' heard is not in a list, use null.',
'1. Never invent a code. treatmentType and prosthesis[].types[] must be codes from the',
' lists below. labId must be an id from the lab list. If what you heard is not in a',
' list, use null (or omit it from prosthesis[].types[]).',
'2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
" flip to the viewer's point of view.",
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
' If no deadline was mentioned, use due.kind = "none".',
'4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
' what was heard.',
'4. Copy the exact spoken words for each tooth, jaw and prosthesis instruction into',
' "spoken", so the clinician can see what was heard.',
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
' one is not.',
'',
@@ -71,8 +132,24 @@ export function buildExtractionPrompt(
'TREATMENT TYPE CODES',
codeList(catalog.treatmentTypes),
'',
'PROSTHESIS TYPE CODES',
codeList(catalog.prosthesisTypes),
'PROSTHESIS WORK — prosthesis[]',
'One entry per spoken instruction: "these targets get these jobs". Each entry is',
'{ targets, types, spoken }:',
'- targets: the teeth OR jaw(s) this instruction is for. A jaw target is a tooth object',
' with "arch" set ("upper", "lower", or "both" for both jaws) and "position" left null —',
' never invent a tooth number for a jaw-level appliance. A tooth target is the normal',
' fdi / arch+side+position shape above.',
'- types: one or more codes from the tree below, applied to every target in this entry.',
' More than one code means a STACK on the same tooth — e.g. an abutment plus a crown on',
' one implant site: types: ["zirconia_abutment", "monolithic_zirconia"].',
' If only the general term was said ("روکش", "veneer") and not a specific material, use',
' the CATEGORY or SUBCATEGORY code instead of guessing a leaf.',
'- A tooth named with no job at all still belongs in the top-level "teeth" list, not here.',
'- If several teeth share one job, list them all as targets in one entry rather than',
' repeating the entry — "12 and 13, PFM crown on both" -> one entry, two targets.',
'',
'PROSTHESIS TYPE CODES (tree — CATEGORY and SUBCATEGORY are marked; the rest are leaves)',
prosthesisTree(catalog),
'',
'LABS THIS CLINIC CAN SEND TO',
catalog.labs.length > 0
@@ -82,7 +159,7 @@ export function buildExtractionPrompt(
'OTHER FIELDS',
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
'- comment: clinical notes, in the language spoken. Omit the parts already captured as',
' treatment type, teeth or deadline.',
' treatment type, teeth, prosthesis work or deadline.',
'- labMatchExact: true only when the spoken name matched a lab name exactly.',
].join('\n');

View File

@@ -1,10 +1,22 @@
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi';
import {
PROSTHESIS_TYPES,
PROSTHESIS_CATEGORY_LABELS,
PROSTHESIS_SUBCATEGORY_LABELS,
} from '../../../prisma/catalog-seed-data';
import {
resolveConnectedSpans,
resolveProsthesis,
resolveProsthesisAssignment,
resolveVoiceIntent,
type ProsthesisLeaf,
type ResolveContext,
} from './extraction.resolver';
import type { ToothIntent, VoiceIntent } from './voice.types';
import type {
ProsthesisAssignment,
ToothIntent,
UnresolvedItem,
VoiceIntent,
} from './voice.types';
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
kind: 'explicit',
@@ -12,12 +24,75 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
spoken,
});
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',
},
];
const CTX: ResolveContext = {
todayIso: '2025-10-11',
weekStartJs: 6, // Saturday — the fa week
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
prosthesisLeaves: PROSTHESIS_LEAVES,
prosthesisCategoryCodes: new Set([
'crown',
'implant',
'removable',
'appliance',
]),
prosthesisSubcategoryCodes: new Set(['night_guard']),
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
};
@@ -111,122 +186,256 @@ describe('resolveConnectedSpans', () => {
});
});
describe('resolveProsthesis', () => {
const allowed = CTX.prosthesisTypeCodes;
it('expands the default across every tooth', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
['14', '15'],
allowed,
describe('resolveProsthesisAssignment', () => {
function resolve(assignment: ProsthesisAssignment, index = 0) {
const unresolved: UnresolvedItem[] = [];
const resolved = resolveProsthesisAssignment(
assignment,
index,
CTX,
unresolved,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'15': 'monolithic_zirconia',
return { resolved, unresolved };
}
it('resolves explicit tooth targets to FDI codes', () => {
const { resolved } = resolve({
targets: [tooth('12'), tooth('13')],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام برای ۱۲ و ۱۳',
});
expect(result.prosthesis?.complete).toBe(true);
expect(resolved.targets.sort()).toEqual(['12', '13']);
expect(resolved.types).toEqual(['pfm_crown']);
});
it('applies per-tooth overrides on top of the default', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
},
['14', '26'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'26': 'pfm_crown',
it('resolves a stack — more than one type on the same target', () => {
const { resolved } = resolve({
targets: [tooth('12')],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
});
expect(result.prosthesis?.complete).toBe(true);
});
it('marks the map incomplete when a tooth ends up untyped', () => {
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
const result = resolveProsthesis(
{
defaultType: null,
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
},
['14', '15'],
allowed,
);
expect(result.prosthesis?.complete).toBe(false);
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
});
it('rejects a catalog code the clinic does not have', () => {
const result = resolveProsthesis(
{ defaultType: 'gold_foil', overrides: [] },
['14'],
allowed,
);
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
expect(result.prosthesis).toBeNull();
expect(result.unresolved).toEqual([
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
expect(resolved.targets).toEqual(['12']);
expect(resolved.types.sort()).toEqual([
'monolithic_zirconia',
'zirconia_abutment',
]);
});
it('ignores an override for a tooth that is not selected', () => {
const result = resolveProsthesis(
{
defaultType: 'monolithic_zirconia',
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
},
['14'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
expect(result.unresolved).toEqual([
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
]);
it('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]);
});
it('reports no prosthesis at all when the object carries nothing usable', () => {
// An empty-but-present map would paint a plain restoration with a fabricated
// "incomplete, cannot ship" warning.
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
expect(
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
).toBeNull();
it('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);
}
});
it('reports no prosthesis when there are no teeth to type', () => {
const result = resolveProsthesis(
{ defaultType: 'monolithic_zirconia', overrides: [] },
[],
allowed,
);
expect(result.prosthesis).toBeNull();
it('reports a code not in the supplied catalog', () => {
const { resolved, unresolved } = resolve({
targets: [tooth('12')],
types: ['gold_foil'],
spoken: '',
});
it('distinguishes a tooth it could not understand from one that is not selected', () => {
// Different corrective actions: add the tooth, versus repeat yourself.
const result = resolveProsthesis(
expect(resolved.types).toEqual([]);
expect(unresolved).toEqual([
{
defaultType: 'monolithic_zirconia',
overrides: [
{
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
type: 'pfm_crown',
spoken: 'gold_foil',
reason: 'unknown_catalog_code',
assignmentIndex: 0,
},
],
},
['14'],
allowed,
);
expect(result.unresolved).toEqual([
{ spoken: 'نود و نه', reason: 'malformed' },
]);
});
it('returns null when no prosthesis was spoken', () => {
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
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 },
]);
});
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();
}
},
);
});
describe('resolveVoiceIntent', () => {
@@ -235,7 +444,7 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('14'), tooth('15')],
connectedSpans: [],
comment: ' حساسیت به سرما ',
prosthesis: null,
prosthesis: [],
labId: null,
labMatchExact: false,
due: null,
@@ -246,7 +455,7 @@ describe('resolveVoiceIntent', () => {
expect(result.treatmentType).toBe('restoration');
expect(result.teeth).toEqual(['14', '15']);
expect(result.comment).toBe('حساسیت به سرما');
expect(result.prosthesis).toBeNull();
expect(result.prosthesisAssignments).toEqual([]);
expect(result.unresolved).toEqual([]);
});
@@ -299,27 +508,38 @@ describe('resolveVoiceIntent', () => {
expect(result.labMatchExact).toBe(false);
});
it('applies prosthesis over the span-expanded tooth set', () => {
it('forces treatmentType to prosthesis when an assignment resolves', () => {
const result = resolveVoiceIntent(
{
...base,
treatmentType: 'prosthesis',
teeth: [tooth('14')],
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
treatmentType: 'restoration',
prosthesis: [
{
targets: [tooth('12')],
types: ['pfm_crown'],
spoken: 'روکش برای ۱۲',
},
],
},
CTX,
);
// 15 was never spoken but is part of the bridge, so it must carry a type too.
expect(result.teeth).toEqual(['14', '15', '16']);
expect(result.prosthesis?.complete).toBe(true);
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
'14',
'15',
'16',
expect(result.treatmentType).toBe('prosthesis');
expect(result.prosthesisAssignments).toEqual([
{ targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' },
]);
});
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');
});
it('resolves a due date through the same context', () => {
const result = resolveVoiceIntent(
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
@@ -328,7 +548,7 @@ describe('resolveVoiceIntent', () => {
expect(result.dueDate).toBe('2025-10-16');
});
it('collects unresolved items from every stage', () => {
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
const result = resolveVoiceIntent(
{
...base,
@@ -336,16 +556,34 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('51', 'شیری')],
connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
prosthesis: [
{
targets: [tooth('99', 'نود و نه')],
types: ['pfm_crown'],
spoken: '',
},
],
},
CTX,
);
const reasons = result.unresolved.map((u) => u.reason).sort();
expect(reasons).toEqual([
'invalid_date',
'malformed',
'not_permanent_tooth',
'span_not_same_arch',
'unknown_catalog_code',
]);
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);
});
it('treats an empty comment as absent', () => {

View File

@@ -1,7 +1,14 @@
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
isFdiTooth,
normalizeFdiCode,
sameArch,
sortInArchOrder,
teethBetweenInclusive,
toFdi,
type Arch,
type PatientSide,
} from '../../common/fdi';
import { resolveDueDate } from './due-date.resolver';
import {
@@ -10,9 +17,10 @@ import {
} from './tooth-intent.resolver';
import type {
ConnectedSpanIntent,
ProsthesisIntent,
ProsthesisAssignment,
ToothIntent,
UnresolvedItem,
UnresolvedReason,
VoiceIntent,
} from './voice.types';
@@ -22,16 +30,16 @@ export type ResolvedToothGroup = {
teeth: string[];
};
export type ResolvedProsthesis = {
/** FDI code → prosthesis type code. */
byTooth: Record<string, string>;
/**
* True when every selected tooth carries a code. A prosthesis detail cannot be shipped
* otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap
* here rather than letting it fail at dispatch.
/**
* One spoken instruction, resolved: these targets (FDI codes, or `'UA'`/`'LA'` jaw sentinels —
* mirrors the manual chart's own convention) get these leaf codes. Empty `targets` or `types`
* means every target, or every type, this assignment named turned out unresolved; the
* assignment still appears so its index keeps meaning for `UnresolvedItem.assignmentIndex`.
*/
complete: boolean;
missingTeeth: string[];
export type ResolvedProsthesisAssignment = {
targets: string[];
types: string[];
spoken: string;
};
export type ResolvedExtraction = {
@@ -39,23 +47,33 @@ export type ResolvedExtraction = {
teeth: string[];
toothSelectionGroups: ResolvedToothGroup[];
comment: string | null;
prosthesis: ResolvedProsthesis | null;
prosthesisAssignments: ResolvedProsthesisAssignment[];
labId: string | null;
labMatchExact: boolean;
dueDate: string | null;
unresolved: UnresolvedItem[];
};
/** A leaf prosthesis catalog entry, as `ProsthesisCatalogService.list()` already returns it. */
export type ProsthesisLeaf = {
code: string;
category: string;
subcategory: string;
chartRegion: string;
};
export type ResolveContext = {
todayIso: string;
/** JS weekday index the clinician's week starts on — see weekStartForLocale. */
weekStartJs: number;
treatmentTypeCodes: ReadonlySet<string>;
prosthesisTypeCodes: ReadonlySet<string>;
prosthesisLeaves: readonly ProsthesisLeaf[];
prosthesisCategoryCodes: ReadonlySet<string>;
prosthesisSubcategoryCodes: ReadonlySet<string>;
linkedLabIds: ReadonlySet<string>;
};
function spokenOf(intent: ToothIntent): string {
function spokenOf(intent: unknown): string {
const spoken = (intent as { spoken?: unknown })?.spoken;
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
}
@@ -165,73 +183,259 @@ export function resolveConnectedSpans(
};
}
/**
* A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is
* how clinicians actually speak.
*/
export function resolveProsthesis(
intent: ProsthesisIntent | null | undefined,
teeth: readonly string[],
allowed: ReadonlySet<string>,
): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } {
if (!intent || typeof intent !== 'object')
return { prosthesis: null, unresolved: [] };
// --- Prosthesis assignments -------------------------------------------------------------
const unresolved: UnresolvedItem[] = [];
const defaultType = resolveCatalogCode(intent.defaultType, allowed);
if (intent.defaultType != null && !defaultType) {
type TargetResolution =
| { kind: 'tooth'; fdi: string }
| { kind: 'jaw'; arch: 'upper' | 'lower' | 'both' }
| { kind: 'unresolved'; reason: UnresolvedReason; candidates?: string[] };
const QUADRANT_ARCHES: readonly Arch[] = ['upper', 'lower'];
const QUADRANT_SIDES: readonly PatientSide[] = [
'patient_right',
'patient_left',
];
/** The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. */
function quadrantCandidatesForTarget(
target: Extract<ToothIntent, { kind: 'positional' }>,
): string[] {
const arches =
target.arch === 'upper' || target.arch === 'lower'
? [target.arch]
: QUADRANT_ARCHES;
const sides =
target.side === 'patient_right' || target.side === 'patient_left'
? [target.side]
: QUADRANT_SIDES;
const codes: string[] = [];
for (const arch of arches) {
for (const side of sides) {
const fdi = toFdi(arch, side, target.position);
if (fdi) codes.push(fdi);
}
}
return codes.sort();
}
/**
* A prosthesis assignment target is a tooth or a jaw — nothing on the wire declares which.
* A position with no quadrant is the familiar "دو" ambiguity; no position at all, with an
* arch, names a jaw; no position AND no arch means the jaw itself was never spoken.
*/
function resolveAssignmentTarget(target: ToothIntent): TargetResolution {
if (!target || typeof target !== 'object') {
return { kind: 'unresolved', reason: 'malformed' };
}
if (target.kind === 'explicit') {
const fdi = normalizeFdiCode((target as { fdi?: unknown }).fdi);
if (isFdiTooth(fdi)) return { kind: 'tooth', fdi };
// Quadrants 1-4 are permanent; a well-formed quadrant+position reaching here is 5-8:
// deciduous. Anything else is noise.
return {
kind: 'unresolved',
reason: /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed',
};
}
if (target.kind === 'positional') {
const positionGiven =
typeof target.position === 'number' && !Number.isNaN(target.position);
const archGiven =
target.arch === 'upper' ||
target.arch === 'lower' ||
target.arch === 'both';
const sideGiven =
target.side === 'patient_right' || target.side === 'patient_left';
if (positionGiven) {
if (
!Number.isInteger(target.position) ||
target.position < 1 ||
target.position > 8
) {
return { kind: 'unresolved', reason: 'position_out_of_range' };
}
if (archGiven && target.arch !== 'both' && sideGiven) {
const fdi = toFdi(target.arch, target.side, target.position);
if (fdi) return { kind: 'tooth', fdi };
}
// A position was said but the tooth's own quadrant was not (or "both" was said for
// what must be a single tooth) — the familiar "دو" ambiguity.
return {
kind: 'unresolved',
reason: 'tooth_missing_quadrant',
candidates: quadrantCandidatesForTarget(target),
};
}
if (archGiven) return { kind: 'jaw', arch: target.arch };
// Neither a tooth position nor a jaw was said.
return {
kind: 'unresolved',
reason: 'arch_not_spoken',
candidates: ['upper', 'lower'],
};
}
return { kind: 'unresolved', reason: 'malformed' };
}
function archSentinels(arch: 'upper' | 'lower' | 'both'): string[] {
if (arch === 'upper') return [ARCH_TOOTH_UPPER];
if (arch === 'lower') return [ARCH_TOOTH_LOWER];
return [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER];
}
type TypeClassification =
| { kind: 'leaf'; code: string; chartRegion: string }
| {
kind: 'category' | 'subcategory';
code: string;
leafCodes: string[];
regions: Set<string>;
}
| { kind: 'unknown' };
/** `types[]` may hold a leaf, or one category / subcategory code — the namespaces are disjoint. */
function classifyTypeCode(
rawCode: unknown,
ctx: ResolveContext,
): TypeClassification {
if (typeof rawCode !== 'string' || !rawCode.trim())
return { kind: 'unknown' };
const code = rawCode.trim();
const leaf = ctx.prosthesisLeaves.find((l) => l.code === code);
if (leaf) return { kind: 'leaf', code, chartRegion: leaf.chartRegion };
if (ctx.prosthesisCategoryCodes.has(code)) {
const leaves = ctx.prosthesisLeaves.filter((l) => l.category === code);
return {
kind: 'category',
code,
leafCodes: leaves.map((l) => l.code),
regions: new Set(leaves.map((l) => l.chartRegion)),
};
}
if (ctx.prosthesisSubcategoryCodes.has(code)) {
const leaves = ctx.prosthesisLeaves.filter((l) => l.subcategory === code);
return {
kind: 'subcategory',
code,
leafCodes: leaves.map((l) => l.code),
regions: new Set(leaves.map((l) => l.chartRegion)),
};
}
return { kind: 'unknown' };
}
const TOOTH_REGIONS = new Set(['crown', 'root']);
const JAW_REGIONS = new Set(['arch']);
function acceptableRegions(kind: 'tooth' | 'jaw'): ReadonlySet<string> {
return kind === 'tooth' ? TOOTH_REGIONS : JAW_REGIONS;
}
/**
* One spoken instruction, resolved. Stack legality (`canStackLeaf`, screw-retained exclusion)
* is never checked here — that lives in the frontend's `prosthesisTree.ts` (decision 38); this
* only confirms a code exists and that its region suits its target.
*/
export function resolveProsthesisAssignment(
assignment: ProsthesisAssignment,
index: number,
ctx: ResolveContext,
unresolved: UnresolvedItem[],
): ResolvedProsthesisAssignment {
const rawTypes = Array.isArray(assignment?.types) ? assignment.types : [];
const leafCodes = new Set<string>();
const regionSet = new Set<string>();
for (const rawCode of rawTypes) {
const classification = classifyTypeCode(rawCode, ctx);
if (classification.kind === 'leaf') {
leafCodes.add(classification.code);
regionSet.add(classification.chartRegion);
} else if (
classification.kind === 'category' ||
classification.kind === 'subcategory'
) {
unresolved.push({
spoken: String(intent.defaultType),
spoken: spokenOf(assignment) || classification.code,
reason: 'prosthesis_type_ambiguous',
candidates: [...classification.leafCodes].sort(),
assignmentIndex: index,
});
// A category whose leaves share one region is informative even before the leaf is
// picked; a mixed one (only `removable` today) defers the region check entirely
// (decision 49) rather than risk `code_not_valid_for_target` on a category that has
// not been narrowed yet.
if (classification.regions.size === 1) {
for (const region of classification.regions) regionSet.add(region);
}
} else if (typeof rawCode === 'string' && rawCode.trim()) {
unresolved.push({
spoken: rawCode,
reason: 'unknown_catalog_code',
assignmentIndex: index,
});
}
const byTooth: Record<string, string> = {};
const selected = new Set(teeth);
if (defaultType) {
for (const tooth of teeth) byTooth[tooth] = defaultType;
}
const overrides: ProsthesisIntent['overrides'] = Array.isArray(
intent.overrides,
)
? intent.overrides
const rawTargets = Array.isArray(assignment?.targets)
? assignment.targets
: [];
for (const override of overrides) {
const tooth = resolveToothIntent(override?.tooth);
const type = resolveCatalogCode(override?.type, allowed);
const spoken = spokenOf(override?.tooth) || String(override?.type ?? '');
if (!tooth) {
unresolved.push({ spoken, reason: 'malformed' });
continue;
const targets = new Set<string>();
for (const rawTarget of rawTargets) {
const resolution = resolveAssignmentTarget(rawTarget);
const spoken = spokenOf(rawTarget);
if (resolution.kind === 'unresolved') {
unresolved.push(
resolution.candidates
? {
spoken,
reason: resolution.reason,
candidates: resolution.candidates,
assignmentIndex: index,
}
// A tooth we understood perfectly well but which is not part of this detail. Saying
// so is actionable ("add tooth 37, or drop it"); calling it malformed is not.
if (!selected.has(tooth)) {
unresolved.push({ spoken, reason: 'tooth_not_selected' });
: { spoken, reason: resolution.reason, assignmentIndex: index },
);
continue;
}
if (!type) {
unresolved.push({ spoken, reason: 'unknown_catalog_code' });
continue;
}
byTooth[tooth] = type;
}
// Nothing usable was said about prosthesis. Returning an empty-but-present map would
// paint a plain restoration with a fabricated "incomplete, cannot ship" warning.
if (Object.keys(byTooth).length === 0) {
return { prosthesis: null, unresolved };
// Nothing to validate against yet — a bare category is deferred, and an empty types[]
// means the target is simply named with no job (left to the caller to render struck
// through, using `teeth` minus every assignment's resolved targets).
const valid =
regionSet.size === 0 ||
[...regionSet].some((region) =>
acceptableRegions(resolution.kind).has(region),
);
if (!valid) {
unresolved.push({
spoken,
reason: 'code_not_valid_for_target',
assignmentIndex: index,
});
continue;
}
if (resolution.kind === 'tooth') {
targets.add(resolution.fdi);
} else {
for (const sentinel of archSentinels(resolution.arch))
targets.add(sentinel);
}
}
const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
return {
prosthesis: {
byTooth,
complete: missingTeeth.length === 0,
missingTeeth,
},
unresolved,
targets: [...targets],
types: [...leafCodes],
spoken: spokenOf(assignment),
};
}
@@ -262,12 +466,18 @@ export function resolveVoiceIntent(
});
}
const prosthesisResult = resolveProsthesis(
intent?.prosthesis,
spanResult.teeth,
ctx.prosthesisTypeCodes,
const rawAssignments = Array.isArray(intent?.prosthesis)
? intent.prosthesis
: [];
const prosthesisAssignments = rawAssignments.map((assignment, index) =>
resolveProsthesisAssignment(assignment, index, ctx, unresolved),
);
// `prosthesis` is the only labDependent treatment type — any assignment that actually
// landed on a target forces it, and the sheet locks that row while it is ticked (decision 41).
const hasResolvedAssignment = prosthesisAssignments.some(
(a) => a.targets.length > 0,
);
unresolved.push(...prosthesisResult.unresolved);
const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
if (due.unresolved) unresolved.push(due.unresolved);
@@ -287,11 +497,11 @@ export function resolveVoiceIntent(
}
return {
treatmentType,
treatmentType: hasResolvedAssignment ? 'prosthesis' : treatmentType,
teeth: spanResult.teeth,
toothSelectionGroups: spanResult.groups,
comment,
prosthesis: prosthesisResult.prosthesis,
prosthesisAssignments,
labId,
labMatchExact: labId ? intent?.labMatchExact === true : false,
dueDate: due.dueDate,

View File

@@ -23,8 +23,7 @@ const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
teeth: [],
connectedSpans: [],
comment: null,
prosthesisDefaultType: null,
prosthesisOverrides: [],
prosthesis: [],
labId: null,
labMatchExact: false,
due: emptyDue,
@@ -177,26 +176,70 @@ describe('toVoiceIntent', () => {
expect(result.due).toEqual({ kind: 'lunar_month' });
});
it('reports no prosthesis when neither a default nor an override was given', () => {
expect(toVoiceIntent(wire()).prosthesis).toBeNull();
it('yields an empty array, never null, when nothing was spoken about prosthesis', () => {
expect(toVoiceIntent(wire()).prosthesis).toEqual([]);
});
it('builds a prosthesis intent from a default alone', () => {
const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' }));
expect(result.prosthesis).toEqual({
defaultType: 'pfm_crown',
overrides: [],
});
});
it('builds a prosthesis intent from overrides alone', () => {
it('narrows a prosthesis assignment, targets and types alike', () => {
const result = toVoiceIntent(
wire({
prosthesisOverrides: [{ tooth: positionalTooth, type: 'pfm_crown' }],
prosthesis: [
{
targets: [{ ...positionalTooth, fdi: '12' }],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
},
],
}),
);
expect(result.prosthesis?.defaultType).toBeNull();
expect(result.prosthesis?.overrides).toHaveLength(1);
expect(result.prosthesis).toEqual([
{
targets: [{ kind: 'explicit', fdi: '12', spoken: 'شش بالا راست' }],
types: ['zirconia_abutment', 'monolithic_zirconia'],
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
},
]);
});
it('narrows a jaw target — arch given, position null', () => {
const result = toVoiceIntent(
wire({
prosthesis: [
{
targets: [
{
spoken: 'فک بالا',
fdi: null,
arch: 'both',
side: null,
position: null,
},
],
types: ['night_guard_soft'],
spoken: 'نایت گارد هر دو فک',
},
],
}),
);
expect(result.prosthesis[0].targets[0]).toEqual({
kind: 'positional',
arch: 'both',
side: null,
position: Number.NaN,
spoken: 'فک بالا',
});
});
it('survives a malformed prosthesis entry', () => {
expect(() =>
toVoiceIntent(
wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }),
),
).not.toThrow();
const result = toVoiceIntent(
wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }),
);
expect(result.prosthesis).toEqual([{ targets: [], types: [], spoken: '' }]);
});
it('narrows connected spans', () => {

View File

@@ -2,7 +2,7 @@ import { normalizeFdiCode } from '../../common/fdi';
import type {
ConnectedSpanIntent,
DueIntent,
ProsthesisIntent,
ProsthesisAssignment,
ToothIntent,
VoiceIntent,
Weekday,
@@ -20,7 +20,7 @@ export type WireToothIntent = {
spoken: string;
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */
fdi: string | null;
arch: 'upper' | 'lower' | null;
arch: 'upper' | 'lower' | 'both' | null;
side: 'patient_right' | 'patient_left' | null;
position: number | null;
};
@@ -39,13 +39,18 @@ export type WireDue = {
d: number | null;
};
export type WireProsthesisAssignment = {
targets: WireToothIntent[];
types: string[];
spoken: string;
};
export type WireVoiceIntent = {
treatmentType: string | null;
teeth: WireToothIntent[];
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
comment: string | null;
prosthesisDefaultType: string | null;
prosthesisOverrides: { tooth: WireToothIntent; type: string }[];
prosthesis: WireProsthesisAssignment[];
labId: string | null;
labMatchExact: boolean;
due: WireDue;
@@ -58,15 +63,21 @@ const TOOTH_SCHEMA = {
properties: {
spoken: {
type: 'string',
description: 'The exact transcript words for this tooth.',
description: 'The exact transcript words for this tooth (or jaw).',
},
fdi: {
type: ['string', 'null'],
description:
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
'when the tooth was described in words instead of numbered.',
'when the tooth was described in words instead of numbered, or the target is a jaw.',
},
arch: {
type: ['string', 'null'],
enum: ['upper', 'lower', 'both', null],
description:
'"both" is only ever used for a jaw-level prosthesis target (e.g. an appliance for ' +
'both jaws), never for a single tooth.',
},
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
side: {
type: ['string', 'null'],
enum: ['patient_right', 'patient_left', null],
@@ -75,7 +86,33 @@ const TOOTH_SCHEMA = {
position: {
type: ['integer', 'null'],
description:
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI ' +
'code. Null when this target is a jaw rather than a tooth.',
},
},
} as const;
const PROSTHESIS_ASSIGNMENT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['targets', 'types', 'spoken'],
properties: {
targets: {
type: 'array',
description: 'The teeth or jaws this instruction applies to.',
items: TOOTH_SCHEMA,
},
types: {
type: 'array',
description:
'Prosthesis type codes to apply to every target above — a stack, e.g. an abutment ' +
'plus a crown on the same tooth. A code from the CATEGORY or SUBCATEGORY lists is ' +
'fine when only the general term was said.',
items: { type: 'string' },
},
spoken: {
type: 'string',
description: 'The exact transcript words for this instruction.',
},
},
} as const;
@@ -88,8 +125,7 @@ export const VOICE_INTENT_JSON_SCHEMA = {
'teeth',
'connectedSpans',
'comment',
'prosthesisDefaultType',
'prosthesisOverrides',
'prosthesis',
'labId',
'labMatchExact',
'due',
@@ -114,19 +150,12 @@ export const VOICE_INTENT_JSON_SCHEMA = {
type: ['string', 'null'],
description: 'Clinical notes, in the spoken language.',
},
prosthesisDefaultType: {
type: ['string', 'null'],
description:
'A prosthesis type CODE applied to every tooth unless overridden.',
},
prosthesisOverrides: {
prosthesis: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['tooth', 'type'],
properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } },
},
description:
'One entry per spoken instruction: these targets get these jobs. No default and no ' +
'overrides — every entry names its own targets.',
items: PROSTHESIS_ASSIGNMENT_SCHEMA,
},
labId: {
type: ['string', 'null'],
@@ -190,7 +219,7 @@ function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
}
return {
kind: 'positional',
arch: wire?.arch as 'upper' | 'lower',
arch: wire?.arch as 'upper' | 'lower' | 'both',
side: wire?.side as 'patient_right' | 'patient_left',
position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
spoken,
@@ -236,36 +265,34 @@ function toDueIntent(wire: WireDue | undefined | null): DueIntent | null {
}
}
function toProsthesisAssignment(
wire: WireProsthesisAssignment | undefined | null,
): ProsthesisAssignment {
const targets = Array.isArray(wire?.targets) ? wire.targets : [];
const types = Array.isArray(wire?.types) ? wire.types : [];
return {
targets: targets.map(toToothIntent),
types: types.filter((code): code is string => typeof code === 'string'),
spoken: typeof wire?.spoken === 'string' ? wire.spoken : '',
};
}
export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
const overrides = Array.isArray(wire?.prosthesisOverrides)
? wire.prosthesisOverrides
: [];
const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : [];
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
from: toToothIntent(span?.from),
to: toToothIntent(span?.to),
}));
const hasProsthesis =
wire?.prosthesisDefaultType != null || overrides.length > 0;
const prosthesis: ProsthesisIntent | null = hasProsthesis
? {
defaultType: wire?.prosthesisDefaultType ?? null,
overrides: overrides.map((o) => ({
tooth: toToothIntent(o?.tooth),
type: o?.type,
})),
}
: null;
return {
treatmentType: wire?.treatmentType ?? null,
teeth: teeth.map(toToothIntent),
connectedSpans,
comment: wire?.comment ?? null,
prosthesis,
prosthesis: assignments.map(toProsthesisAssignment),
labId: wire?.labId ?? null,
labMatchExact: wire?.labMatchExact === true,
due: toDueIntent(wire?.due),

View File

@@ -12,7 +12,18 @@ const CONFIG = {
const CATALOG = {
treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }],
prosthesisTypes: [{ code: 'pfm_crown', label: 'روکش پی‌اف‌ام' }],
prosthesisTypes: [
{
code: 'pfm_crown',
label: 'روکش پی‌اف‌ام',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
],
prosthesisCategories: [{ code: 'crown', label: 'روکش‌ها' }],
prosthesisSubcategories: [],
labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }],
};
@@ -111,8 +122,21 @@ describe('OpenRouterExtractionProvider', () => {
],
connectedSpans: [],
comment: null,
prosthesisDefaultType: 'pfm_crown',
prosthesisOverrides: [],
prosthesis: [
{
targets: [
{
spoken: 'یک چهار',
fdi: '14',
arch: null,
side: null,
position: null,
},
],
types: ['pfm_crown'],
spoken: 'روکش پی‌اف‌ام روی ۱۴',
},
],
labId: 'lab-1',
labMatchExact: true,
due: {

View File

@@ -34,7 +34,22 @@ export interface AsrProvider {
export type ExtractionCatalog = {
/** Catalog codes with their labels in the actor's locale, so the model matches spoken words. */
treatmentTypes: { code: string; label: string }[];
prosthesisTypes: { code: string; label: string }[];
/**
* Leaf prosthesis types — the full tree shape, not just code/label, so the prompt can
* present it as a tree and mark which codes are jaw-level (`chartRegion: 'arch'`).
*/
prosthesisTypes: {
code: string;
label: string;
category: string;
subcategory: string;
chartRegion: string;
stackGroup: string;
}[];
/** The 7 category codes, so "روکش" resolves to `crown` rather than a guessed leaf. */
prosthesisCategories: { code: string; label: string }[];
/** The 5 subcategory codes (veneer, inlay, onlay, overlay, night_guard). */
prosthesisSubcategories: { code: string; label: string }[];
/** The clinic's linked labs — a closed choice list. */
labs: { id: string; name: string }[];
};

View File

@@ -138,8 +138,12 @@ export class VoiceService {
todayIso,
weekStartJs: weekStartForLocale(catalogLocale),
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
prosthesisTypeCodes: new Set(
catalog.prosthesisTypes.map((t) => t.code),
prosthesisLeaves: catalog.prosthesisTypes,
prosthesisCategoryCodes: new Set(
catalog.prosthesisCategories.map((c) => c.code),
),
prosthesisSubcategoryCodes: new Set(
catalog.prosthesisSubcategories.map((c) => c.code),
),
linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
});
@@ -244,9 +248,17 @@ export class VoiceService {
organizationId: string,
locale: string,
): Promise<ExtractionCatalog> {
const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([
const [
treatmentTypes,
prosthesisTypes,
prosthesisCategories,
prosthesisSubcategories,
labs,
] = await Promise.all([
this.treatmentCatalog.list(locale, null),
this.prosthesisCatalog.list(locale),
this.prosthesisCatalog.listCategories(locale),
this.prosthesisCatalog.listSubcategories(locale),
this.listLinkedLabs(organizationId),
]);
@@ -254,10 +266,11 @@ export class VoiceService {
treatmentTypes: treatmentTypes
.filter((entry) => entry.availableInTreatment)
.map((entry) => ({ code: entry.code, label: entry.label })),
prosthesisTypes: prosthesisTypes.map((entry) => ({
code: entry.code,
label: entry.label,
})),
// `buildCatalog` used to throw away category/subcategory/chartRegion/stackGroup here —
// the prompt now presents the catalog as the tree it is (§5).
prosthesisTypes,
prosthesisCategories,
prosthesisSubcategories,
labs,
};
}
@@ -333,7 +346,7 @@ export class VoiceService {
treatmentType: resolved.treatmentType != null,
teeth: resolved.teeth.length,
comment: resolved.comment != null,
prosthesisComplete: resolved.prosthesis?.complete ?? null,
prosthesisAssignments: resolved.prosthesisAssignments.length,
lab: resolved.labId != null,
dueDate: resolved.dueDate != null,
},

View File

@@ -8,14 +8,19 @@ import type { Arch, PatientSide } from '../../common/fdi';
* mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful.
*/
/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */
/**
* A single spoken tooth reference, OR — inside a prosthesis assignment's `targets` — a jaw.
* Nothing on the wire declares "this is a jaw": a positional reference with no `position` at
* all names an arch instead of a tooth, and the resolver tells the two apart (§5, §6).
* `arch: 'both'` only ever appears here, never on a resolved single tooth.
*/
export type ToothIntent =
| { kind: 'explicit'; fdi: string; spoken: string }
| {
kind: 'positional';
arch: Arch;
arch: Arch | 'both';
side: PatientSide;
/** 1 = central incisor … 8 = third molar. */
/** 1 = central incisor … 8 = third molar. Absent (NaN) when the target is a jaw. */
position: number;
spoken: string;
};
@@ -42,10 +47,18 @@ export type Weekday = (typeof WEEKDAYS)[number];
/** Two teeth defining an inclusive connected (bridge) span. */
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
export type ProsthesisIntent = {
/** Catalog code applied to every tooth unless overridden. */
defaultType: string | null;
overrides: { tooth: ToothIntent; type: string }[];
/**
* One spoken instruction: these targets get these jobs. Replaces the old
* `prosthesisDefaultType` + `prosthesisOverrides` pair — a default with per-tooth overrides
* has a precedence rule, and a precedence rule has a wrong side (decision 35).
*/
export type ProsthesisAssignment = {
/** Each one a tooth or a jaw — see `ToothIntent`. */
targets: ToothIntent[];
/** Leaf codes, or one category / subcategory code the clinician named generically. */
types: string[];
/** The transcript span, echoed back to the clinician. */
spoken: string;
};
export type VoiceIntent = {
@@ -53,7 +66,8 @@ export type VoiceIntent = {
teeth: ToothIntent[];
connectedSpans: ConnectedSpanIntent[];
comment: string | null;
prosthesis: ProsthesisIntent | null;
/** Empty array, never null. */
prosthesis: ProsthesisAssignment[];
/** Must be one of the linked-lab ids supplied in the prompt, or null. */
labId: string | null;
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */
@@ -67,10 +81,15 @@ export type UnresolvedReason =
| 'position_out_of_range'
/** A position was understood but no quadrant was spoken — four teeth match. */
| 'tooth_missing_quadrant'
/** A category or subcategory was heard, not a material. */
| 'prosthesis_type_ambiguous'
/** A jaw-level appliance with no jaw spoken. */
| 'arch_not_spoken'
/** An arch code aimed at a tooth, or a tooth code aimed at a jaw. */
| 'code_not_valid_for_target'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export type UnresolvedItem = {
@@ -78,8 +97,15 @@ export type UnresolvedItem = {
spoken: string;
reason: UnresolvedReason;
/**
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
* Values still consistent with what was heard: FDI codes for `tooth_missing_quadrant`,
* leaf codes for `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`. The
* sheet offers these as chips.
*/
candidates?: string[];
/**
* Set only when this item was raised while resolving a `prosthesis` assignment's targets or
* types. A picked chip then inherits that assignment's `types[]` (or supplies the missing
* leaf to it) instead of resolving to a jobless tooth (decision 50).
*/
assignmentIndex?: number;
};

View File

@@ -22,25 +22,25 @@ Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
| # | Work item | Repo | Status | Notes / refs |
|---|-----------|------|--------|--------------|
| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | | §9. The Safari failure |
| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | | §2 render policy, `TreatmentWorkspace.tsx` |
| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | | §12. One dev dep, one config, one script |
| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | | §5. `voice.service.ts` already receives all four |
| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | | §5, `extraction.wire.ts` |
| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | | §5, §6, `extraction.resolver.ts` |
| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | | §6 reason table |
| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | | §5. Only `prosthesis` is `labDependent` |
| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | | §5, `extraction.prompt.ts` |
| 10 | Backend Jest suites for items 68, including the namespace-disjointness assertion | `dyolink` | | §12 |
| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | | `types/voice.ts` |
| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | | §6, §7 |
| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | | §7. `crownColors` / `rootColors` / `archHighlight` already exist |
| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | | §6, §7 |
| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | | §12 |
| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | | i18n is mandatory, not a follow-up |
| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | | §12 |
| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | | §5, decision 47. **Must land before item 9** |
| 19 | Unresolved items carry `assignmentIndex`; a picked chip inherits that assignment's jobs | `dyolink` | | §6, decision 50. Without it the quadrant chip is dead on a prosthesis detail |
| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | | §9. `frontend/src/lib/voice/audioFormat.ts` — the final `return null` after the loop is now `return ''` |
| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | | §2 render policy, `TreatmentWorkspace.tsx` |
| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | | §12. `vitest@3.2.7`, `frontend/vitest.config.ts`, `npm run test` script; `CLAUDE.md` Tests section rewritten |
| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | | §5. `voice.service.ts` now forwards the full `ProsthesisTypeCatalogEntry[]` plus category/subcategory lists |
| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | | §5, `extraction.wire.ts``prosthesis: WireProsthesisAssignment[]` |
| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | | §5, §6, `extraction.resolver.ts``resolveProsthesisAssignment`, `resolveAssignmentTarget`, `classifyTypeCode` |
| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | | §6 reason table`voice.types.ts` |
| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | | §5. `resolveVoiceIntent` `hasResolvedAssignment` |
| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | | §5, `extraction.prompt.ts``prosthesisTree()` renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge |
| 10 | Backend Jest suites for items 68, including the namespace-disjointness assertion | `dyolink` | | §12. `extraction.resolver.spec.ts` — disjointness test reads `catalog-seed-data.ts`'s live `PROSTHESIS_TYPES`, not a written count |
| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | | `types/voice.ts``VoiceProsthesisAssignment[]`, `assignmentIndex` |
| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | | §6, §7`prosthesisTargetLines`, `joblessProsthesisTargets`, `withChosenArch`/`withChosenProsthesisLeaf` |
| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | | §7. Merged "Teeth and prosthesis" row; tooth / jaw / material chip kinds |
| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | | §6, §7 — via `prosthesisTargetLines`, which routes every stack through `applyLeafToJobs` |
| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | | §12. 37 tests total, `npx vitest run` green |
| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | | i18n is mandatory, not a follow-up — new `voiceUnresolved.*` reasons, `voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused`; retired `voiceProsthesisIncomplete` (all-or-nothing gone) |
| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | 🟡 | Machine gates green (below). Manual pass and `prisma:migrate`/`prisma:seed` against a live DB **not run** — this sandbox has no working Docker daemon (see note below) |
| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | 🟡 | §5, decision 47. Enum + migration SQL + seed data all written and `prisma generate` succeeded; migration **not applied** to a running Postgres (Docker unavailable) |
| 19 | Unresolved items carry `assignmentIndex`; a picked chip inherits that assignment's jobs | `dyolink` | | §6, decision 50. `UnresolvedItem.assignmentIndex`; `VoiceReviewSheet` chips inherit via `withChosenTeeth(...,index)` / `withChosenArch` / `withChosenProsthesisLeaf` |
## Key decisions
@@ -261,3 +261,183 @@ no code to revisit.
| Note 5 — disjointness counts | Verified independently: **42** leaf codes (the surveyor's 41 missed `screw_retained`, a multi-line `implant(` call) and **5** subcategories (the spec said 4, missing `night_guard`). The test now asserts against the live catalog, not a written count | §5, §12 |
Referee relays: 0. Gate repairs: 0. Neither phase was reached.
---
## 2026-09-07 — Implementation (dyolink, extraction contract + resolvers + review sheet)
All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job.
### Backend
- `backend/prisma/schema.prisma``PROSTHESIS_CATEGORY`, `PROSTHESIS_SUBCATEGORY` added to
`CatalogEntityKind` (additive, no data loss).
- `backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql`
hand-written `ALTER TYPE ... ADD VALUE` migration, following the exact pattern of the repo's
one precedent (`20260718180000_lab_case_activity_task_assigned`). **Not applied** — see
"Not run" below.
- `backend/prisma/catalog-seed-data.ts``PROSTHESIS_CATEGORY_LABELS` (7) and
`PROSTHESIS_SUBCATEGORY_LABELS` (5), worded from the frontend's existing `category_*`/`sub_*`
message keys (decision 47/48), folded into `CATALOG_TRANSLATIONS`.
- `backend/src/common/fdi.ts``ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` sentinels, mirroring the
frontend's `prosthesisTree.ts` convention so both sides speak the same jaw target.
- `backend/src/modules/voice/voice.types.ts``ProsthesisAssignment` replaces
`ProsthesisIntent` (default+overrides retired, decision 35); `UnresolvedReason` gains
`prosthesis_type_ambiguous`, `arch_not_spoken`, `code_not_valid_for_target`, loses
`tooth_not_selected`; `UnresolvedItem.assignmentIndex` added (decision 50).
- `backend/src/modules/voice/extraction.wire.ts` — wire schema carries `prosthesis:
WireProsthesisAssignment[]`; `WireToothIntent.arch` gains `'both'`.
- `backend/src/modules/voice/extraction.resolver.ts` — rewritten: `resolveAssignmentTarget`
(tooth vs. jaw, mirrors the old `tooth_missing_quadrant`/new `arch_not_spoken` split),
`classifyTypeCode` (leaf / category / subcategory, disjoint namespaces), and
`resolveProsthesisAssignment` composing both plus the region-validity check (deferred for a
mixed-region category — `removable` today, decision 49 — computed generically from the
catalog's own chart regions rather than hardcoding the category name). `resolveVoiceIntent`
forces `treatmentType` to `prosthesis` when any assignment resolves a target (decision 41).
`resolveProsthesis`/`ResolvedProsthesis` retired outright.
- `backend/src/modules/voice/extraction.prompt.ts` — `prosthesisTree()` renders the catalog as
CATEGORY → (SUBCATEGORY →) leaf from the data `buildCatalog` supplies; no catalog knowledge
hardcoded in the prompt text itself.
- `backend/src/modules/voice/voice.providers.ts`, `voice.service.ts` — `ExtractionCatalog`
carries the full leaf shape plus `prosthesisCategories`/`prosthesisSubcategories`;
`buildCatalog` no longer strips `category`/`subcategory`/`chartRegion`/`stackGroup`.
- `backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts` —
`listCategories()`/`listSubcategories()`, resolved through `CatalogLabelService` like every
other catalog label.
- Jest: `extraction.resolver.spec.ts` rewritten around `resolveProsthesisAssignment` (stacks,
jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the
live `PROSTHESIS_TYPES`, region validity both ways, the `removable` deferral, assignment-index
attribution); `extraction.wire.spec.ts` and `openrouter.provider.spec.ts` updated for the new
wire shape. 209 backend tests pass (121 in `modules/voice`).
### Frontend
- `frontend/src/lib/voice/audioFormat.ts` — `pickRecordingMimeType`'s final fallback is now `''`
instead of `null` (item 1's Safari fix — modern Safari's `isTypeSupported` can reject every
preferred container yet still record when let choose).
- `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx` — `voiceForEditor` now also
requires `isMediaRecorderSupported()`; `applyVoiceResult` rewritten: teeth vs. prosthesis are
mutually exclusive per `isLabDependentResult`, the prosthesis stack is built through
`prosthesisTargetLines` (which routes every leaf through `applyLeafToJobs`), and jaw targets
write `LabCaseToothProsthesisDraft` rows keyed on the `UA`/`LA` sentinels — no separate
arch-specific code path needed beyond what `prosthesisTree.ts` already provides.
- `frontend/src/types/voice.ts` — `VoiceProsthesisAssignment[]` replaces the byTooth map;
`VoiceUnresolvedItem.assignmentIndex`.
- `frontend/src/components/treatment/voiceReviewRows.ts` — rewritten: `isLabDependentResult`,
merged-row `voiceRowAvailability`, `withChosenArch`/`withChosenProsthesisLeaf` (decision 50),
`prosthesisTargetLines` (previews the stack via `applyLeafToJobs`, names refused jobs),
`joblessProsthesisTargets` (decision 40 — named, struck through, never silently dropped or
silently applied), `prosthesisChartData` (crown/root/arch tints for the merged row's chart).
- `frontend/src/components/ui/treatment/VoiceReviewSheet.tsx` — merged "Teeth and prosthesis"
row for a labDependent type; three independent candidate-chip kinds (tooth, jaw, leaf) each
folding through their own `voiceReviewRows` helper; `voiceProsthesisIncomplete` warning
removed (all-or-nothing retired).
- i18n: `en.json`/`fa.json`/`nl.json` — `voiceUnresolved.*` updated for the new/retired reasons,
`voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused` added,
`voiceProsthesisIncomplete` removed.
- `frontend/package.json`, `frontend/vitest.config.ts` — `vitest@3.2.7` (pinned to a version
whose peer `@types/node` range still includes the repo's `^20`; vitest 4/5 require `>=22`),
`npm run test` → `vitest run`, alias-only config (`@` → `src/`).
- `frontend/src/components/treatment/prosthesisTree.spec.ts`,
`voiceReviewRows.spec.ts` — 37 Vitest cases covering stack legality, `applyLeafToJobs`
precedence, `toothRegionColors`, row availability, chip folding, the merged-row preview, and
the jobless/pending distinction.
- `CLAUDE.md` — Tests section rewritten; frontend command table gains `npx vitest run`.
### Verification run
- `cd backend && npm test` — 209/209 pass (121 in `modules/voice`).
- `cd backend && npm run build` — clean (after `npm install`, which pulled in `@sentry/nestjs`
that `node_modules` was missing — unrelated to this change, pre-existing on this checkout).
- `cd backend && npx prisma generate` — succeeds against the updated schema (no DB needed);
confirms `CatalogEntityKind.PROSTHESIS_CATEGORY`/`PROSTHESIS_SUBCATEGORY` compile everywhere
they're used.
- `cd frontend && npx tsc --noEmit` — clean.
- `cd frontend && npx vitest run` — 37/37 pass.
- `cd frontend && npm run build` — production build succeeds.
- ESLint on every touched file — 0 errors, 0 new warnings (pre-existing warnings elsewhere in
`TreatmentWorkspace.tsx`, unrelated to this change, left untouched).
### Not run (environment limitation, not a design gap)
- `npm run prisma:migrate && npm run prisma:seed` against a live Postgres — this sandbox has no
running Docker daemon (`docker info` never came up after several minutes and `open -a Docker`
did not launch it), so the migration was never applied to a database and the new
`CatalogTranslation` rows were never seeded. The migration SQL and seed data are written and
reviewed against the one existing precedent in this repo; **run both before merging**.
- The full manual pass in §12 (Safari/iPad recording, live extraction against the real OpenRouter
API, the specific stack/jaw/ambiguity scenarios) — needs a browser and a live backend, neither
available in this session.
### Deviations from spec / judgement calls made while implementing
- **A target with empty `types[]` still resolves as a target**, with `types: []` on its
assignment — not excluded from the assignment's `targets` array. The sheet (frontend) treats
`types.length === 0` with no matching `prosthesis_type_ambiguous` unresolved item as "jobless,
struck through" (decision 40), and the same empty-types-plus-ambiguous-item combination as
"pending a material pick" instead. This keeps the wire contract simple (no extra field) at the
cost of the frontend doing that one bit of inference from `unresolved` — documented in both
`extraction.resolver.ts` and `voiceReviewRows.ts`.
- **The mixed-region deferral (decision 49) is computed generically** from each category's
actual leaf chart-regions (`regions.size === 1` → validate immediately, else defer) rather than
special-cased for `removable` by name. This also defers `implant` (which spans `root` and
`crown` via `screw_retained`) — the spec's prose says "only `removable` today" as an
observation about the current catalog, not an instruction to hardcode that name, and deferring
a category no test forbids deferring is the safer default.
- **`voice.dto.ts`/`voice.controller.ts` needed no changes.** The scout's item 6 ("validation for
new schema shape") does not apply: the DTO validates the client's audio submission
(`audio`/`format`/`timeZone`/`durationMs`/`locale`), which is unrelated to the LLM's structured
output shape that changed. Confirmed by reading both files; not a silent skip.
---
## 2026-09-07 — Re-run reached Ship; stopped, and the challenge phase is incomplete
Recorded by hand: the `record-stop:Ship` agent failed on the session spend limit before it
could write this.
**Stop reason:** `no MR template found in dyolink — refusing to invent a description.` Expected
and flagged at preflight: `.gitea/` holds only `workflows/`, and the remote is Gitea, not GitLab.
A `--dry` run pushes nothing regardless.
| Phase | Result |
|---|---|
| Gap-check | **clear** — the five gaps closed in `77e2ed4` were accepted |
| Scout | 1 of 3 Explore sweeps returned; 2 ended without structured output |
| Implement | **done** — all 19 work items, ~3,100 insertions across 25 files + 4 new files |
| Gate | **green** — backend 16 suites / 209 tests, `nest build` 0, `prisma validate` ok; frontend Vitest 2 files / 37 tests, `tsc --noEmit` 0, `next build` 0 |
| Refute | **incomplete** — `correctness` refuted with 2 findings; `regression-risk` never ran (spend limit) |
| Ship | stopped, no template. `clerk` also failed on the spend limit |
Gate repairs: 0. Referee relays: 0. Nothing committed, nothing pushed.
### Two confirmed findings — verified by hand, not taken on the critic's word
1. **`VoiceReviewSheet.tsx:169` — a picked tooth chip is silently dropped.** `pickCandidate`
sets `teeth: prev.teeth || available.teeth`, but `available` is memoised from `effective`,
which depends on `chosenTeeth`. Both `setChosenTeeth` and `setSelection` run in the same
handler, so the updater closes over the pre-pick `available`, where `available.teeth` is
`false` because `result.teeth` is empty. `prev.teeth` is false too, so it stays false
permanently. The row then renders with the tooth on the chart and the box unticked, and Apply
drops it. This is a variant of the original live-test failure — "ترمیم برای دندون دو".
2. **`VoiceReviewSheet.tsx:213` + `TreatmentWorkspace.tsx:2215` — decision 41 not implemented.**
The treatmentType row is a plain `toggle('treatmentType')` with no lock, and
`applyVoiceResult` derives `labDependent` from `result.treatmentType` rather than the
`detail.treatmentType` it writes. Untick the type row on a forced-prosthesis recording and a
`restoration` detail is saved carrying prosthesis lab rows — the state decision 41 exists to
make unreachable.
### Lint
Backend touched files: 0 errors, 0 warnings. Frontend touched files: 10 warnings, all
pre-existing in `TreatmentWorkspace.tsx`. The repo-wide backend baseline (1,288 errors, 1,107
prettier-fixable) is untouched by this diff.
## Next steps / open questions
- The diff is **unreviewed on the `regression-risk` lens**. Green gate plus one refuting lens is
not the design's bar; the script continued only because the threshold counts refusals and the
second critic errored rather than refused.
- Fix the two findings, then resume from Refute so both lenses grade the same diff.
- The Ship phase cannot pass in this repo until there is an MR template, or until the MR is
opened by hand. Opening one is gated regardless.

View File

@@ -926,20 +926,25 @@
"voiceProcessing": "Reading the recording…",
"voiceReviewTitle": "Check what was understood",
"voiceNothingExtracted": "Nothing usable was picked up from that recording.",
"voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.",
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
"voiceNotUnderstood": "Not understood",
"voicePickTooth": "Which tooth?",
"voicePickJaw": "Which jaw?",
"voiceTeethAndProsthesis": "Teeth and prosthesis",
"voiceNoProsthesisHeard": "no prosthesis heard",
"voiceStackRefused": "not added — cannot be combined",
"voiceDiscard": "Discard",
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
"voiceUnresolved": {
"not_permanent_tooth": "not a permanent tooth",
"position_out_of_range": "not a valid tooth position",
"tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”",
"prosthesis_type_ambiguous": "a general term, not a specific material — pick one below",
"arch_not_spoken": "which jaw was this for? — pick below",
"code_not_valid_for_target": "does not fit that tooth or jaw",
"malformed": "could not be read",
"span_not_same_arch": "a bridge cannot span both jaws",
"unknown_catalog_code": "not in this clinics list",
"tooth_not_selected": "that tooth is not part of this detail",
"invalid_date": "not a usable date"
},
"voiceFailed": "Voice entry failed. Please try again."

View File

@@ -927,20 +927,25 @@
"voiceProcessing": "در حال پردازش گفتار…",
"voiceReviewTitle": "بررسی آنچه دریافت شد",
"voiceNothingExtracted": "از این ضبط چیز قابل استفاده‌ای برداشت نشد.",
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.",
"voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
"voiceNotUnderstood": "شناسایی نشد",
"voicePickTooth": "کدام دندان؟",
"voicePickJaw": "کدام فک؟",
"voiceTeethAndProsthesis": "دندان‌ها و پروتز",
"voiceNoProsthesisHeard": "پروتزی شنیده نشد",
"voiceStackRefused": "افزوده نشد — قابل ترکیب نیست",
"voiceDiscard": "انصراف",
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
"voiceUnresolved": {
"not_permanent_tooth": "دندان دائمی نیست",
"position_out_of_range": "شماره دندان معتبر نیست",
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
"prosthesis_type_ambiguous": "یک عنوان کلی است، نه یک متریال مشخص — یکی را از پایین انتخاب کنید",
"arch_not_spoken": "برای کدام فک بود؟ — از پایین انتخاب کنید",
"code_not_valid_for_target": "با این دندان یا فک همخوانی ندارد",
"malformed": "قابل خواندن نبود",
"span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد",
"unknown_catalog_code": "در فهرست این مطب نیست",
"tooth_not_selected": "این دندان بخشی از این مورد نیست",
"invalid_date": "تاریخ قابل استفاده نیست"
},
"voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید."

View File

@@ -926,20 +926,25 @@
"voiceProcessing": "Opname wordt gelezen…",
"voiceReviewTitle": "Controleer wat is begrepen",
"voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.",
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
"voiceNotUnderstood": "Niet begrepen",
"voicePickTooth": "Welk element?",
"voicePickJaw": "Welke kaak?",
"voiceTeethAndProsthesis": "Elementen en prothese",
"voiceNoProsthesisHeard": "geen prothese gehoord",
"voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd",
"voiceDiscard": "Verwerpen",
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
"voiceUnresolved": {
"not_permanent_tooth": "geen blijvend element",
"position_out_of_range": "geen geldige elementpositie",
"tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”",
"prosthesis_type_ambiguous": "een algemene term, geen specifiek materiaal — kies hieronder",
"arch_not_spoken": "voor welke kaak was dit? — kies hieronder",
"code_not_valid_for_target": "past niet bij dat element of die kaak",
"malformed": "kon niet worden gelezen",
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
"tooth_not_selected": "dat element hoort niet bij dit onderdeel",
"invalid_date": "geen bruikbare datum"
},
"voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw."

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -36,6 +37,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^3.2.7"
}
}

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
applyLeafToJobs,
canStackLeaf,
catalogByCode,
effectiveChartRegion,
PARTIAL_DENTURE_CODE,
toothRegionColors,
} from './prosthesisTree';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'monolithic_zirconia',
sortOrder: 2,
label: 'Monolithic Zirconia',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'zirconia_abutment',
sortOrder: 3,
label: 'Zirconia Abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
stackGroup: 'implant',
},
{
code: 'screw_retained',
sortOrder: 4,
label: 'Screw Retained',
category: 'implant',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'implant',
},
{
code: 'cast_post_core',
sortOrder: 5,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: PARTIAL_DENTURE_CODE,
sortOrder: 6,
label: 'Partial Denture',
category: 'removable',
subcategory: '',
chartRegion: 'arch',
stackGroup: 'arch',
},
{
code: 'night_guard_soft',
sortOrder: 7,
label: 'Night Guard',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const byCode = catalogByCode(CATALOG);
describe('canStackLeaf', () => {
it('allows an implant plus a crown restoration on the same tooth', () => {
expect(canStackLeaf(['zirconia_abutment'], 'pfm_crown', byCode)).toBe(true);
});
it('refuses a post & core alongside an implant', () => {
expect(canStackLeaf(['zirconia_abutment'], 'cast_post_core', byCode)).toBe(false);
});
it('refuses an implant alongside a post & core', () => {
expect(canStackLeaf(['cast_post_core'], 'zirconia_abutment', byCode)).toBe(false);
});
it('refuses a second restoration once screw-retained already paints the crown', () => {
expect(canStackLeaf(['screw_retained'], 'pfm_crown', byCode)).toBe(false);
});
it('allows two restorations to replace each other (no illegal stack)', () => {
expect(canStackLeaf(['pfm_crown'], 'monolithic_zirconia', byCode)).toBe(true);
});
it('refuses a code the catalog does not have', () => {
expect(canStackLeaf([], 'gold_foil', byCode)).toBe(false);
});
});
describe('applyLeafToJobs', () => {
it('stacks an implant and a crown restoration on one tooth', () => {
const jobs = applyLeafToJobs(['zirconia_abutment'], 'pfm_crown', byCode);
expect(jobs.sort()).toEqual(['pfm_crown', 'zirconia_abutment'].sort());
});
it('a same-stack-group leaf replaces rather than stacking beside the old one', () => {
// PFM previewed on 13, then PFZ heard for the same tooth: the second live test this repo
// ran on real recordings — the first stack rule bug that had to be fixed.
const jobs = applyLeafToJobs(['pfm_crown'], 'monolithic_zirconia', byCode);
expect(jobs).toEqual(['monolithic_zirconia']);
});
it('leaves the jobs untouched when the stack rules refuse the leaf', () => {
const jobs = applyLeafToJobs(['zirconia_abutment'], 'cast_post_core', byCode);
expect(jobs).toEqual(['zirconia_abutment']);
});
});
describe('effectiveChartRegion', () => {
it('overrides partial_denture to crown even though its catalog chartRegion is arch', () => {
expect(effectiveChartRegion({ code: PARTIAL_DENTURE_CODE, chartRegion: 'arch' })).toBe('crown');
});
it('leaves every other code as the catalog says', () => {
expect(effectiveChartRegion({ code: 'zirconia_abutment', chartRegion: 'root' })).toBe('root');
});
});
describe('toothRegionColors', () => {
it('paints a crown-region code into crownColors only', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'pfm_crown' }],
CATALOG,
);
expect(crown['12']).toBeTruthy();
expect(root['12']).toBeUndefined();
});
it('paints a root-region code into rootColors only', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'zirconia_abutment' }],
CATALOG,
);
expect(root['12']).toBeTruthy();
expect(crown['12']).toBeUndefined();
});
it('paints both crown and root for an arch-region code on a real tooth', () => {
const { crown, root } = toothRegionColors(
[{ tooth: '12', prosthesisTypeCode: 'night_guard_soft' }],
CATALOG,
);
expect(crown['12']).toBeTruthy();
expect(root['12']).toBeTruthy();
});
it('skips jaw sentinel rows — they have no crown or root to paint', () => {
const { crown, root } = toothRegionColors(
[
{ tooth: ARCH_TOOTH_UPPER, prosthesisTypeCode: 'night_guard_soft' },
{ tooth: ARCH_TOOTH_LOWER, prosthesisTypeCode: 'night_guard_soft' },
],
CATALOG,
);
expect(Object.keys(crown)).toHaveLength(0);
expect(Object.keys(root)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,274 @@
import { describe, expect, it } from 'vitest';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { VoiceExtractionResult } from '@/types/voice';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from './prosthesisTree';
import {
countSelected,
hasAnythingToApply,
initialVoiceSelection,
isLabDependentResult,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
withChosenTeeth,
} from './voiceReviewRows';
const CATALOG: ProsthesisCatalogEntry[] = [
{
code: 'pfm_crown',
sortOrder: 1,
label: 'PFM Crown',
category: 'crown',
subcategory: '',
chartRegion: 'crown',
stackGroup: 'restoration',
},
{
code: 'zirconia_abutment',
sortOrder: 2,
label: 'Zirconia Abutment',
category: 'implant',
subcategory: '',
chartRegion: 'root',
stackGroup: 'implant',
},
{
code: 'cast_post_core',
sortOrder: 3,
label: 'Cast Post & Core',
category: 'post_core',
subcategory: '',
chartRegion: 'root',
stackGroup: 'post_core',
},
{
code: 'night_guard_soft',
sortOrder: 4,
label: 'Night Guard',
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
},
];
const LAB_DEPENDENT = new Set(['prosthesis']);
function baseResult(overrides: Partial<VoiceExtractionResult> = {}): VoiceExtractionResult {
return {
transcript: '',
treatmentType: 'restoration',
teeth: [],
toothSelectionGroups: [],
comment: null,
prosthesisAssignments: [],
labId: null,
labMatchExact: false,
dueDate: null,
unresolved: [],
...overrides,
};
}
describe('isLabDependentResult', () => {
it('is true only when the resolved type is in the labDependent set', () => {
expect(isLabDependentResult(baseResult({ treatmentType: 'prosthesis' }), LAB_DEPENDENT)).toBe(
true,
);
expect(isLabDependentResult(baseResult({ treatmentType: 'restoration' }), LAB_DEPENDENT)).toBe(
false,
);
});
});
describe('voiceRowAvailability', () => {
it('shows a plain teeth row for a non-lab-dependent type', () => {
const result = baseResult({ teeth: ['14'] });
const available = voiceRowAvailability(result, LAB_DEPENDENT);
expect(available.teeth).toBe(true);
expect(available.prosthesis).toBe(false);
});
it('merges teeth and prosthesis into one row for a lab-dependent type', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
});
const available = voiceRowAvailability(result, LAB_DEPENDENT);
expect(available.teeth).toBe(false);
expect(available.prosthesis).toBe(true);
});
it('shows the merged row for a jaw appliance with no teeth at all', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [
{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' },
],
});
expect(voiceRowAvailability(result, LAB_DEPENDENT).prosthesis).toBe(true);
});
});
describe('initialVoiceSelection', () => {
it('ticks an available lab-dependent prosthesis row even when the stack is incomplete', () => {
// All-or-nothing is retired (decision 40) — an incomplete map no longer blocks a tick.
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: ['12'], types: [], spoken: '' }],
});
expect(initialVoiceSelection(result, LAB_DEPENDENT).prosthesis).toBe(true);
});
it('never ticks lab when the match was inexact', () => {
const result = baseResult({ labId: 'lab-1', labMatchExact: false });
expect(initialVoiceSelection(result, LAB_DEPENDENT).lab).toBe(false);
});
});
describe('countSelected', () => {
it('intersects the selection with availability rather than counting raw ticks', () => {
const selection = { treatmentType: true, teeth: true, comment: true, prosthesis: true, lab: true, dueDate: true };
const available = { treatmentType: true, teeth: false, comment: true, prosthesis: false, lab: true, dueDate: false };
expect(countSelected(selection, available)).toBe(3);
});
});
describe('withChosenTeeth', () => {
it('folds a plain candidate into the top-level teeth list', () => {
const result = baseResult({ teeth: ['14'] });
const next = withChosenTeeth(result, ['26']);
expect(next.teeth).toEqual(['14', '26']);
});
it('folds an assignment-scoped candidate into that assignment target list, not the plain list', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: [], types: ['pfm_crown'], spoken: 'دندون دو روکش' }],
});
const next = withChosenTeeth(result, ['12'], 0);
expect(next.prosthesisAssignments[0].targets).toEqual(['12']);
expect(next.teeth).toEqual([]);
});
});
describe('withChosenArch', () => {
it('folds a picked jaw into the assignment that named no jaw at all', () => {
const result = baseResult({
treatmentType: 'prosthesis',
prosthesisAssignments: [{ targets: [], types: ['night_guard_soft'], spoken: 'نایت گارد' }],
});
const next = withChosenArch(result, 0, 'upper');
expect(next.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
});
it('picking both jaws is how a both-jaw appliance is expressed', () => {
let next = withChosenArch(
baseResult({ prosthesisAssignments: [{ targets: [], types: [], spoken: '' }] }),
0,
'upper',
);
next = withChosenArch(next, 0, 'lower');
expect(next.prosthesisAssignments[0].targets.sort()).toEqual(
[ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER].sort(),
);
});
});
describe('withChosenProsthesisLeaf', () => {
it('supplies the missing leaf to the assignment that only named a category', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['12'], types: [], spoken: 'روکش' }],
});
const next = withChosenProsthesisLeaf(result, 0, 'pfm_crown');
expect(next.prosthesisAssignments[0].types).toEqual(['pfm_crown']);
// A resolved assignment forces the type — the row locks exactly as the backend does.
expect(next.treatmentType).toBe('prosthesis');
});
});
describe('prosthesisTargetLines', () => {
it('previews the stack that will actually land, through applyLeafToJobs', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['zirconia_abutment', 'pfm_crown'], spoken: '' }],
CATALOG,
);
expect(lines).toEqual([{ target: '12', isJaw: false, applied: ['zirconia_abutment', 'pfm_crown'], refused: [] }]);
});
it('names a refused job rather than silently dropping or silently applying it', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['zirconia_abutment', 'cast_post_core'], spoken: '' }],
CATALOG,
);
expect(lines[0].applied).toEqual(['zirconia_abutment']);
expect(lines[0].refused).toEqual(['cast_post_core']);
});
it('marks a jaw target as such', () => {
const lines = prosthesisTargetLines(
[{ targets: [ARCH_TOOTH_UPPER], types: ['night_guard_soft'], spoken: '' }],
CATALOG,
);
expect(lines[0].isJaw).toBe(true);
});
});
describe('joblessProsthesisTargets', () => {
it('names a tooth left in the plain teeth list with no matching assignment', () => {
const result = baseResult({
teeth: ['12', '13'],
prosthesisAssignments: [{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
});
expect(joblessProsthesisTargets(result)).toEqual(['13']);
});
it('names an assignment target whose types were empty from the start', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: '' }],
});
expect(joblessProsthesisTargets(result)).toEqual(['13']);
});
it('does not call a target jobless while it is pending a material pick', () => {
const result = baseResult({
prosthesisAssignments: [{ targets: ['13'], types: [], spoken: 'روکش' }],
unresolved: [{ spoken: 'روکش', reason: 'prosthesis_type_ambiguous', assignmentIndex: 0 }],
});
expect(joblessProsthesisTargets(result)).toEqual([]);
});
});
describe('prosthesisChartData', () => {
it('derives the arch highlight from applied jaw jobs, upper and lower alike', () => {
const lines = prosthesisTargetLines(
[{ targets: [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER], types: ['night_guard_soft'], spoken: '' }],
CATALOG,
);
const data = prosthesisChartData(lines, [], CATALOG);
expect(data.archHighlight).toBe('both');
expect(data.selectedTeeth.size).toBe(0);
});
it('collects every real tooth, applied or jobless, into selectedTeeth', () => {
const lines = prosthesisTargetLines(
[{ targets: ['12'], types: ['pfm_crown'], spoken: '' }],
CATALOG,
);
const data = prosthesisChartData(lines, ['13'], CATALOG);
expect([...data.selectedTeeth].sort()).toEqual(['12', '13']);
});
});
describe('hasAnythingToApply', () => {
it('is false when the recording produced nothing usable', () => {
expect(hasAnythingToApply(baseResult({ treatmentType: null }), LAB_DEPENDENT)).toBe(false);
});
it('is true once any row is available', () => {
expect(hasAnythingToApply(baseResult({ teeth: ['14'] }), LAB_DEPENDENT)).toBe(true);
});
});

View File

@@ -1,35 +1,73 @@
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
applyLeafToJobs,
archSentinels,
canStackLeaf,
catalogByCode,
isArchSentinel,
toothRegionColors,
type ArchTarget,
} from '@/components/treatment/prosthesisTree';
import type { FdiToothId } from '@/types/treatment';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceProsthesisResult,
VoiceProsthesisAssignment,
VoiceUnresolvedItem,
} from '@/types/voice';
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
export function voiceRowAvailability(result: VoiceExtractionResult) {
/** `prosthesis` is the app's only labDependent treatment type today, but this stays generic. */
export function isLabDependentResult(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): boolean {
return Boolean(result.treatmentType && labDependentCodes.has(result.treatmentType));
}
function hasProsthesisWork(result: VoiceExtractionResult): boolean {
return result.prosthesisAssignments.some((a) => a.targets.length > 0 || a.types.length > 0);
}
/**
* Which rows the review sheet renders at all — a row with nothing extracted is noise.
*
* Teeth and prosthesis are never both available: a lab-dependent type merges them into one
* `prosthesis` row (decision 39), because two independent ticks can save an empty detail —
* `persistDraft` prunes a lab-dependent detail to its jobs.
*/
export function voiceRowAvailability(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
) {
const labDependent = isLabDependentResult(result, labDependentCodes);
return {
treatmentType: result.treatmentType != null,
teeth: result.teeth.length > 0,
teeth: !labDependent && result.teeth.length > 0,
prosthesis: labDependent && (result.teeth.length > 0 || hasProsthesisWork(result)),
comment: Boolean(result.comment?.trim()),
prosthesis: result.prosthesis != null,
lab: result.labId != null,
dueDate: result.dueDate != null,
};
}
/**
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
* it is the one extracted value whose error leaves the building; and an incomplete
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
* Everything available ticks itself, with one exception: an inexactly-matched lab, because it
* is the one extracted value whose error leaves the building. All-or-nothing prosthesis maps
* are retired (decision 40) — an incomplete stack no longer blocks a tick.
*/
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
const available = voiceRowAvailability(result);
export function initialVoiceSelection(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): VoiceApplySelection {
const available = voiceRowAvailability(result, labDependentCodes);
return {
treatmentType: available.treatmentType,
teeth: available.teeth,
comment: available.comment,
prosthesis: available.prosthesis && result.prosthesis?.complete === true,
prosthesis: available.prosthesis,
lab: available.lab && result.labMatchExact,
dueDate: available.dueDate,
};
@@ -48,37 +86,75 @@ export function countSelected(
).length;
}
/** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */
function recheckProsthesis(
prosthesis: VoiceProsthesisResult,
teeth: readonly FdiToothId[],
): VoiceProsthesisResult {
const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]);
return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 };
/** Every resolved assignment target, across every assignment. */
function allAssignmentTargets(result: VoiceExtractionResult): Set<string> {
return new Set(result.prosthesisAssignments.flatMap((a) => a.targets));
}
/** Forces treatmentType to `prosthesis` the moment any assignment carries a real target. */
function withProsthesisForced(result: VoiceExtractionResult): VoiceExtractionResult {
const forced = result.prosthesisAssignments.some((a) => a.targets.length > 0);
return forced ? { ...result, treatmentType: 'prosthesis' } : result;
}
/**
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
* Fold a picked FDI/quadrant candidate into the result.
*
* `assignmentIndex` set: the chip came from resolving a `prosthesisAssignments` entry, so the
* pick becomes that assignment's target — inheriting its `types`, not a jobless tooth
* (decision 50). `assignmentIndex` absent: the chip came from the plain `teeth` list and folds
* in there, exactly as before.
*
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
* produced, and tapping it must not deselect that one.
*/
export function withChosenTeeth(
result: VoiceExtractionResult,
chosen: readonly FdiToothId[],
assignmentIndex?: number,
): VoiceExtractionResult {
if (chosen.length === 0) return result;
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
if (assignmentIndex != null) {
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, ...chosen])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
return {
...result,
teeth,
toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups),
prosthesis: result.prosthesis ? recheckProsthesis(result.prosthesis, teeth) : null,
};
}
/** Fold a picked jaw ('upper' / 'lower') into the assignment that named no jaw at all. */
export function withChosenArch(
result: VoiceExtractionResult,
assignmentIndex: number,
arch: 'upper' | 'lower',
): VoiceExtractionResult {
const sentinel = arch === 'upper' ? ARCH_TOOTH_UPPER : ARCH_TOOTH_LOWER;
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, sentinel])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
/** Fold a picked leaf into the assignment that only named a category or subcategory. */
export function withChosenProsthesisLeaf(
result: VoiceExtractionResult,
assignmentIndex: number,
leafCode: string,
): VoiceExtractionResult {
const assignments = result.prosthesisAssignments.map((a, i) =>
i === assignmentIndex ? { ...a, types: [...new Set([...a.types, leafCode])] } : a,
);
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
}
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
export function connectedTeethFromResult(result: VoiceExtractionResult): Set<FdiToothId> {
const connected = new Set<FdiToothId>();
@@ -89,7 +165,138 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
return connected;
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
return Object.values(voiceRowAvailability(result)).some(Boolean);
export type VoiceProsthesisTargetLine = {
target: string;
isJaw: boolean;
/** Leaf codes that will actually land, in landing order. */
applied: string[];
/** Leaf codes the stack rules refused — shown struck through, never silently dropped. */
refused: string[];
};
/**
* The stack that will actually land, built through `applyLeafToJobs` — the same function the
* manual chart writes through (§7). A code the stack rules refuse (an implant plus a post &
* core on one tooth) is named as refused rather than silently dropped or silently applied.
*/
export function prosthesisTargetLines(
assignments: readonly VoiceProsthesisAssignment[],
catalog: readonly ProsthesisCatalogEntry[],
): VoiceProsthesisTargetLine[] {
const byCode = catalogByCode(catalog);
const byTarget = new Map<string, { applied: string[]; refused: string[] }>();
for (const assignment of assignments) {
for (const target of assignment.targets) {
const entry = byTarget.get(target) ?? { applied: [], refused: [] };
for (const code of assignment.types) {
if (canStackLeaf(entry.applied, code, byCode)) {
entry.applied = applyLeafToJobs(entry.applied, code, byCode);
} else {
entry.refused.push(code);
}
}
byTarget.set(target, entry);
}
}
return [...byTarget.entries()].map(([target, { applied, refused }]) => ({
target,
isJaw: isArchSentinel(target),
applied,
refused,
}));
}
/**
* FDI codes / jaw sentinels named somewhere (an assignment target, or the plain `teeth` list)
* but ending up with no job at all — struck through in the sheet reading "no prosthesis heard"
* (decision 40). A target still pending a material pick (a `prosthesis_type_ambiguous` chip
* for its assignment) is not jobless; it is simply not resolved yet.
*/
export function joblessProsthesisTargets(result: VoiceExtractionResult): string[] {
const pendingIndexes = new Set(
result.unresolved
.filter((u) => u.reason === 'prosthesis_type_ambiguous' && u.assignmentIndex != null)
.map((u) => u.assignmentIndex as number),
);
const jobless = new Set<string>();
const covered = new Set<string>();
const pending = new Set<string>();
result.prosthesisAssignments.forEach((assignment, index) => {
if (pendingIndexes.has(index)) {
for (const target of assignment.targets) pending.add(target);
} else if (assignment.types.length === 0) {
for (const target of assignment.targets) jobless.add(target);
} else {
for (const target of assignment.targets) covered.add(target);
}
});
for (const tooth of result.teeth) {
if (!covered.has(tooth) && !pending.has(tooth)) jobless.add(tooth);
}
return [...jobless];
}
export type VoiceProsthesisChartData = {
crownColors: Partial<Record<FdiToothId, string>>;
rootColors: Partial<Record<FdiToothId, string>>;
archHighlight: ArchTarget | null;
/** Every real tooth involved — a target with a job, or a jobless one named alongside it. */
selectedTeeth: Set<FdiToothId>;
};
/** Feeds the merged row's `FdiToothChart` — crown/root tints plus the arch highlight. */
export function prosthesisChartData(
lines: readonly VoiceProsthesisTargetLine[],
jobless: readonly string[],
catalog: readonly ProsthesisCatalogEntry[],
): VoiceProsthesisChartData {
const rows = lines.flatMap((line) =>
line.applied.map((code) => ({ tooth: line.target, prosthesisTypeCode: code })),
);
const { crown, root } = toothRegionColors(rows, catalog);
const hasUpper = lines.some((l) => l.target === ARCH_TOOTH_UPPER && l.applied.length > 0);
const hasLower = lines.some((l) => l.target === ARCH_TOOTH_LOWER && l.applied.length > 0);
const archHighlight: ArchTarget | null =
hasUpper && hasLower ? 'both' : hasUpper ? 'upper' : hasLower ? 'lower' : null;
const selectedTeeth = new Set<FdiToothId>();
for (const line of lines) {
if (!line.isJaw) selectedTeeth.add(line.target as FdiToothId);
}
for (const tooth of jobless) {
if (!isArchSentinel(tooth)) selectedTeeth.add(tooth as FdiToothId);
}
return { crownColors: crown, rootColors: root, archHighlight, selectedTeeth };
}
/** Which unresolved items belong to which chip section — the sheet renders both identically. */
export function unresolvedWithoutAssignment(
result: VoiceExtractionResult,
): VoiceUnresolvedItem[] {
return result.unresolved.filter((u) => u.assignmentIndex == null);
}
export function unresolvedForAssignment(
result: VoiceExtractionResult,
assignmentIndex: number,
): VoiceUnresolvedItem[] {
return result.unresolved.filter((u) => u.assignmentIndex === assignmentIndex);
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
export function hasAnythingToApply(
result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): boolean {
return Object.values(voiceRowAvailability(result, labDependentCodes)).some(Boolean);
}
export { archSentinels, allAssignmentTargets };

View File

@@ -31,6 +31,7 @@ import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { voiceApi } from '@/lib/api/voice';
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
import { isMediaRecorderSupported } from '@/lib/voice/audioFormat';
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
import type {
VoiceApplySelection,
@@ -61,6 +62,10 @@ import {
unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import {
isLabDependentResult,
prosthesisTargetLines,
} from '@/components/treatment/voiceReviewRows';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
@@ -621,9 +626,18 @@ export function TreatmentWorkspace({
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))),
});
/** Absence is the unavailable state — the Add button then renders unsplit. */
/**
* Absence is the unavailable state — the Add button then renders unsplit. Gated on both the
* server's availability response AND the browser's own recording support: without the
* latter check the control rendered on a browser that cannot record and failed on tap
* (the Safari report that started this revision, §2).
*/
const voiceForEditor =
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined;
voiceAvailability?.enabled &&
voiceAvailability.locales.includes(locale) &&
isMediaRecorderSupported()
? voice
: undefined;
const selectedStandalone = useMemo(
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
@@ -2192,7 +2206,13 @@ export function TreatmentWorkspace({
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
if (selection.teeth) {
// Teeth and prosthesis are one row for a lab-dependent type (decision 39) — ticking
// "teeth" independently of "prosthesis" could save an empty detail, since
// `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis`
// alone drives both below; `selection.teeth` only ever applies to the plain row.
const labDependent = isLabDependentResult(result, labDependentCodes);
if (!labDependent && selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
@@ -2203,6 +2223,25 @@ export function TreatmentWorkspace({
detail.comment = result.comment;
}
// The stack that will actually land, built through the same `applyLeafToJobs` the
// manual chart writes through (§7) — a code the stack rules refuse is not applied.
const prosthesisLines =
labDependent && selection.prosthesis
? prosthesisTargetLines(result.prosthesisAssignments, prosthesisCatalog)
: [];
// An assignment target is a selection: a tooth exists on a prosthesis detail only by
// carrying a job (decision 40) — never from the plain `teeth` field.
const prosthesisTeeth = prosthesisLines
.filter((line) => !line.isJaw && line.applied.length > 0)
.map((line) => line.target as FdiToothId);
if (labDependent && selection.prosthesis) {
detail.teeth = prosthesisTeeth;
detail.toothSelectionGroups = groupsFromFlatTeeth(
prosthesisTeeth,
result.toothSelectionGroups,
);
}
const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails);
// persistDraft reads detailsRef, and setDetails has not rendered yet.
@@ -2212,7 +2251,7 @@ export function TreatmentWorkspace({
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
const wantsLabDraft =
(selection.prosthesis && result.prosthesis) ||
prosthesisLines.some((line) => line.applied.length > 0) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
@@ -2225,26 +2264,19 @@ export function TreatmentWorkspace({
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
if (selection.prosthesis && result.prosthesis) {
// byTooth keys are plain strings; the group's teeth are FdiToothId.
const groupOf = (tooth: string) =>
result.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(tooth),
)?.groupId ?? '';
// Only teeth that actually landed on the detail. Unticking "teeth" while
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
// the treatment does not contain — nothing downstream filters them, and they
// would reach task generation as work for teeth nobody is treating.
const detailTeeth = new Set<string>(detail.teeth);
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
.filter(([tooth]) => detailTeeth.has(tooth))
.map(([tooth, prosthesisTypeCode]) => ({
draft.toothProsthesis = prosthesisLines.flatMap((line) => {
const groupId = line.isJaw
? ''
: (detail.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(line.target),
)?.groupId ?? '');
return line.applied.map((prosthesisTypeCode) => ({
detailClientId: detail.clientId,
tooth,
tooth: line.target,
prosthesisTypeCode,
selectionGroupId: groupOf(tooth),
selectionGroupId: groupId,
}));
}
});
const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases);
@@ -2269,8 +2301,10 @@ export function TreatmentWorkspace({
},
[
labCaseDrafts,
labDependentCodes,
persistDraft,
persistLabCases,
prosthesisCatalog,
selectedAppointment?.purpose,
showError,
t,
@@ -3202,6 +3236,7 @@ export function TreatmentWorkspace({
result={voiceResult}
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labDependentCodes={labDependentCodes}
labs={orgs}
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
onDiscard={() => setVoiceResult(null)}

View File

@@ -10,31 +10,44 @@ import {
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '@/components/treatment/prosthesisTree';
import {
connectedTeethFromResult,
countSelected,
hasAnythingToApply,
initialVoiceSelection,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
withChosenTeeth,
} from '@/components/treatment/voiceReviewRows';
import { useLocale } from 'next-intl';
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceUnresolvedItem,
} from '@/types/voice';
interface VoiceReviewSheetProps {
result: VoiceExtractionResult;
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog: ProsthesisCatalogEntry[];
/** `prosthesis` today, but kept generic — the same set the workspace already tracks. */
labDependentCodes: ReadonlySet<string>;
labs: LinkedOrganizationOption[];
/** The result is handed back because the sheet may have added teeth the model missed. */
/** The result is handed back because the sheet may have added teeth or jobs the model missed. */
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
onDiscard: () => void;
}
type ArchPick = 'upper' | 'lower';
/**
* Confirmation step between the model's output and the form.
*
@@ -45,50 +58,131 @@ export function VoiceReviewSheet({
result,
treatmentCatalog,
prosthesisCatalog,
labDependentCodes,
labs,
onApply,
onDiscard,
}: VoiceReviewSheetProps) {
const t = useTranslations('treatment');
const locale = useLocale();
const { formatDate } = useAppFormatters();
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
initialVoiceSelection(result),
initialVoiceSelection(result, labDependentCodes),
);
const [chosen, setChosen] = useState<FdiToothId[]>([]);
// Everything below renders from `effective`, never from `result` — a tooth picked from
// the candidate chips has to reach the rows, the chart and the apply count alike.
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
// Every kind of candidate chip the sheet can offer, tracked separately because each folds
// into the result a different way (decision 50). Toggling off removes only the clinician's
// own pick — nothing the recording already produced is ever un-added.
const [chosenTeeth, setChosenTeeth] = useState<FdiToothId[]>([]);
const [chosenAssignmentTeeth, setChosenAssignmentTeeth] = useState<Record<number, FdiToothId[]>>(
{},
);
const [chosenArches, setChosenArches] = useState<Record<number, ArchPick[]>>({});
const [chosenLeaves, setChosenLeaves] = useState<Record<number, string[]>>({});
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
// Everything below renders from `effective`, never from `result` — a candidate picked from
// the chips has to reach the rows, the chart and the apply count alike.
const effective = useMemo(() => {
let next = withChosenTeeth(result, chosenTeeth);
for (const [index, teeth] of Object.entries(chosenAssignmentTeeth)) {
next = withChosenTeeth(next, teeth, Number(index));
}
for (const [index, arches] of Object.entries(chosenArches)) {
for (const arch of arches) next = withChosenArch(next, Number(index), arch);
}
for (const [index, leaves] of Object.entries(chosenLeaves)) {
for (const leaf of leaves) next = withChosenProsthesisLeaf(next, Number(index), leaf);
}
return next;
}, [result, chosenTeeth, chosenAssignmentTeeth, chosenArches, chosenLeaves]);
const available = useMemo(
() => voiceRowAvailability(effective, labDependentCodes),
[effective, labDependentCodes],
);
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
const nothingToApply = !hasAnythingToApply(effective);
const prosthesisLines = useMemo(
() => prosthesisTargetLines(effective.prosthesisAssignments, prosthesisCatalog),
[effective, prosthesisCatalog],
);
const joblessTargets = useMemo(() => joblessProsthesisTargets(effective), [effective]);
const chartData = useMemo(
() => prosthesisChartData(prosthesisLines, joblessTargets, prosthesisCatalog),
[prosthesisLines, joblessTargets, prosthesisCatalog],
);
const nothingToApply = !hasAnythingToApply(effective, labDependentCodes);
const selectedCount = countSelected(selection, available);
const pickCandidate = (tooth: FdiToothId) => {
const nextChosen = chosen.includes(tooth)
? chosen.filter((t) => t !== tooth)
: [...chosen, tooth];
setChosen(nextChosen);
setSelection((prev) => ({
...prev,
// The teeth row starts unticked whenever the recording produced no teeth of its own,
// and a picked tooth that is not ticked applies nothing.
teeth: true,
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
// clinician's call.
prosthesis:
prev.prosthesis &&
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
}));
const targetLabel = (target: string): string => {
if (target === ARCH_TOOTH_UPPER) return t('upperArch');
if (target === ARCH_TOOTH_LOWER) return t('lowerArch');
return target;
};
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
const isPicked = (item: VoiceUnresolvedItem, code: string): boolean => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken') {
return index != null && (chosenArches[index] ?? []).includes(code as ArchPick);
}
if (item.reason === 'prosthesis_type_ambiguous') {
return index != null && (chosenLeaves[index] ?? []).includes(code);
}
if (index != null) return (chosenAssignmentTeeth[index] ?? []).includes(code as FdiToothId);
return chosenTeeth.includes(code as FdiToothId);
};
const pickCandidate = (item: VoiceUnresolvedItem, code: string) => {
const index = item.assignmentIndex;
if (item.reason === 'arch_not_spoken' && index != null) {
setChosenArches((prev) => {
const cur = prev[index] ?? [];
const arch = code as ArchPick;
return {
...prev,
[index]: cur.includes(arch) ? cur.filter((a) => a !== arch) : [...cur, arch],
};
});
} else if (item.reason === 'prosthesis_type_ambiguous' && index != null) {
setChosenLeaves((prev) => {
const cur = prev[index] ?? [];
return { ...prev, [index]: cur.includes(code) ? cur.filter((l) => l !== code) : [...cur, code] };
});
} else if (index != null) {
setChosenAssignmentTeeth((prev) => {
const cur = prev[index] ?? [];
const tooth = code as FdiToothId;
return {
...prev,
[index]: cur.includes(tooth) ? cur.filter((t2) => t2 !== tooth) : [...cur, tooth],
};
});
} else {
const tooth = code as FdiToothId;
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
}
// A picked candidate has no meaning unless its row is ticked.
setSelection((prev) => ({
...prev,
teeth: prev.teeth || available.teeth,
prosthesis: true,
}));
};
const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => {
if (item.reason === 'arch_not_spoken') return code === 'upper' ? t('upperArch') : t('lowerArch');
if (item.reason === 'prosthesis_type_ambiguous') return labelFor(code, prosthesisCatalog);
return code;
};
const promptFor = (item: VoiceUnresolvedItem): string | null => {
if (item.reason === 'arch_not_spoken') return t('voicePickJaw');
if (item.reason === 'tooth_missing_quadrant') return t('voicePickTooth');
return null;
};
const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
setSelection((prev) => ({ ...prev, [key]: checked }));
@@ -138,6 +232,59 @@ export function VoiceReviewSheet({
</Row>
) : null}
{available.prosthesis ? (
<Row
label={t('voiceTeethAndProsthesis')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
>
<div className="mt-1">
<FdiToothChart
readOnly
compact
scale={0.55}
selected={chartData.selectedTeeth}
crownColors={chartData.crownColors}
rootColors={chartData.rootColors}
archHighlight={chartData.archHighlight}
/>
</div>
<p className="mt-1 text-sm text-text-primary">
{prosthesisLines.map((line, i) => (
<span key={`line-${line.target}`}>
{i > 0 ? ' · ' : ''}
{targetLabel(line.target)}:{' '}
{line.applied.map((code, j) => (
<span key={code}>
{j > 0 ? ' + ' : ''}
{labelFor(code, prosthesisCatalog)}
</span>
))}
{line.refused.map((code) => (
<span
key={code}
title={t('voiceStackRefused')}
className="text-text-muted line-through"
>
{' + '}
{labelFor(code, prosthesisCatalog)}
</span>
))}
</span>
))}
{joblessTargets.map((target, i) => (
<span
key={`jobless-${target}`}
className="text-text-muted line-through"
>
{prosthesisLines.length > 0 || i > 0 ? ' · ' : ''}
{targetLabel(target)}: {t('voiceNoProsthesisHeard')}
</span>
))}
</p>
</Row>
) : null}
{available.comment ? (
<Row
label={t('comments')}
@@ -150,29 +297,6 @@ export function VoiceReviewSheet({
</Row>
) : null}
{available.prosthesis && effective.prosthesis ? (
<Row
label={t('prosthesisColType')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
warning={
effective.prosthesis.complete
? undefined
: t('voiceProsthesisIncomplete', {
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
})
}
>
<span className="text-sm text-text-primary">
{Object.entries(effective.prosthesis.byTooth)
.map(
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
)
.join(' · ')}
</span>
</Row>
) : null}
{available.lab ? (
<Row
label={t('entryStepLab')}
@@ -212,23 +336,29 @@ export function VoiceReviewSheet({
{t(`voiceUnresolved.${item.reason}`)}
{item.candidates && item.candidates.length > 0 ? (
<span className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-text-muted">{t('voicePickTooth')}</span>
{item.candidates.map((tooth) => {
const picked = chosen.includes(tooth as FdiToothId);
{promptFor(item) ? (
<span className="text-text-muted">{promptFor(item)}</span>
) : null}
{item.candidates.map((code) => {
const picked = isPicked(item, code);
return (
<button
key={tooth}
key={code}
type="button"
aria-pressed={picked}
aria-label={t('toothAria', { fdi: tooth })}
onClick={() => pickCandidate(tooth as FdiToothId)}
aria-label={
item.reason === 'tooth_missing_quadrant'
? t('toothAria', { fdi: code })
: candidateLabel(item, code)
}
onClick={() => pickCandidate(item, code)}
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
picked
? 'border-transparent bg-primary text-white'
: 'border-border text-text-primary hover:border-border-strong'
}`}
>
{tooth}
{candidateLabel(item, code)}
</button>
);
})}
@@ -295,12 +425,3 @@ function civilDateToLocalDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1);
}
/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */
function formatToothList(teeth: readonly string[], locale: string): string {
try {
return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]);
} catch {
return teeth.join(', ');
}
}

View File

@@ -22,7 +22,13 @@ export function pickRecordingMimeType(): string | null {
for (const type of PREFERRED_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(type)) return type;
}
return null;
// None of the preferred containers passed `isTypeSupported` — a Safari version whose check
// exists but answers false for a container it can still record (e.g. plain `audio/mp4`).
// The preference list is not a requirement: fall back to the "let the browser choose" hint
// rather than refusing outright. `onstop` derives the real container from
// `recorder.mimeType`, so this is only wrong when the browser genuinely cannot record at
// all — and `new MediaRecorder()` / `recorder.start()` throwing is handled at the call site.
return '';
}
/** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */

View File

@@ -6,10 +6,12 @@ export type VoiceUnresolvedReason =
| 'not_permanent_tooth'
| 'position_out_of_range'
| 'tooth_missing_quadrant'
| 'prosthesis_type_ambiguous'
| 'arch_not_spoken'
| 'code_not_valid_for_target'
| 'malformed'
| 'span_not_same_arch'
| 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date';
export interface VoiceUnresolvedItem {
@@ -17,17 +19,30 @@ export interface VoiceUnresolvedItem {
spoken: string;
reason: VoiceUnresolvedReason;
/**
* FDI codes still consistent with what was heard, when a choice would settle it — the
* review sheet offers them as chips. Only `tooth_missing_quadrant` carries these.
* Values still consistent with what was heard, when a choice would settle it — the review
* sheet offers them as chips. FDI codes for `tooth_missing_quadrant`, leaf codes for
* `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`.
*/
candidates?: string[];
/**
* Set only when this item came from resolving a `prosthesisAssignments` entry. A picked
* chip then inherits that assignment's `types` (or supplies the missing leaf to it) instead
* of resolving to a jobless tooth.
*/
assignmentIndex?: number;
}
export interface VoiceProsthesisResult {
byTooth: Record<string, string>;
/** False means the case cannot ship — every tooth needs a prosthesis type. */
complete: boolean;
missingTeeth: FdiToothId[];
/**
* One spoken instruction, resolved: these targets — FDI codes, or `'UA'`/`'LA'` jaw sentinels
* (`ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` in `prosthesisTree.ts`) — get these leaf codes. Empty
* `types` means the target was named with no job at all (struck through in the sheet); empty
* `targets` with a `prosthesis_type_ambiguous` unresolved item at this index means the target
* is pending a material pick.
*/
export interface VoiceProsthesisAssignment {
targets: string[];
types: string[];
spoken: string;
}
export interface VoiceExtractionResult {
@@ -36,7 +51,7 @@ export interface VoiceExtractionResult {
teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[];
comment: string | null;
prosthesis: VoiceProsthesisResult | null;
prosthesisAssignments: VoiceProsthesisAssignment[];
labId: string | null;
/** When false, the lab row must not tick itself — the name only approximately matched. */
labMatchExact: boolean;

15
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
/**
* Covers the pure helpers only — no React, no DOM. `@/*` mirrors `tsconfig.json`'s path so a
* spec can import the same modules the app does.
*/
export default defineConfig({
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
test: {
include: ['src/**/*.spec.ts'],
},
});