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** | | `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
| `npm run build` | Production build (`output: 'standalone'`) | | `npm run build` | Production build (`output: 'standalone'`) |
| `npm run lint` | ESLint via Next | | `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`. `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 ### 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 ## Deployment

View File

@@ -71,7 +71,11 @@ export const LAB_WORKFLOW_STEPS = [
] as const; ] as const;
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch'; 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 = { export type ProsthesisTypeSeed = {
code: string; code: string;
@@ -87,7 +91,13 @@ export type ProsthesisTypeSeed = {
}; };
const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const; 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 = [ const INDIRECT_LAYERED = [
...SCAN_DESIGN_MODEL, ...SCAN_DESIGN_MODEL,
'milling_wet', 'milling_wet',
@@ -115,9 +125,24 @@ const KEEP_LAYERED = [
'glaze', 'glaze',
'polish_prep', 'polish_prep',
] as const; ] as const;
const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const; const DENTURE = [
const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const; 'intraoral_scan',
const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const; '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( function crown(
code: string, code: string,
@@ -165,7 +190,8 @@ function indirect(
subcategory: indication, subcategory: indication,
chartRegion: 'crown', chartRegion: 'crown',
stackGroup: 'restoration', 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', 'stain',
'glaze', 'glaze',
]), ]),
crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']), crown('full_metal_crown', 5, [
crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']), ...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('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, [ crown('press_ceramic', 9, [
'intraoral_scan', 'intraoral_scan',
'design', 'design',
@@ -243,7 +283,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'model_create', 'model_create',
'polish_prep', '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, [ implant('multi_unit_abutment', 42, [
'intraoral_scan', 'intraoral_scan',
'choosing_abutment', 'choosing_abutment',
@@ -255,7 +299,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
'milling_wet', 'milling_wet',
'polish_prep', 'polish_prep',
]), ]),
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']), implant('zirconia_abutment', 44, [
...SCAN_DESIGN_MODEL,
'milling_dry',
'sinter',
]),
implant( implant(
'screw_retained', 'screw_retained',
45, 45,
@@ -450,7 +498,11 @@ export {
const TREATMENT_LABELS: Record<string, Record<string, string>> = { const TREATMENT_LABELS: Record<string, Record<string, string>> = {
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' }, 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' }, radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' }, endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' }, surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
@@ -460,8 +512,16 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' }, perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' }, pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' }, extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' }, clinic_visit: {
continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' }, en: 'Clinic Visit',
fa: 'ویزیت درمانگاه',
nl: 'Kliniekbezoek',
},
continue_treatment: {
en: 'Continue Treatment',
fa: 'ادامه درمان',
nl: 'Behandeling Voortzetten',
},
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' }, consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' }, filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' }, 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>> = { export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' }, pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' }, pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' }, monolithic_zirconia: {
glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشه‌ای', nl: 'Glaskeramische Kroon' }, en: 'Monolithic Zirconia',
full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' }, fa: 'زیرکونیا مونولیتیک',
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' }, 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' }, pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' }, 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' }, press_ceramic: {
veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' }, en: 'Pressed Ceramic / IPS e.max',
veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' }, fa: 'سرامیک پرس / IPS e.max',
inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' }, nl: 'Perskeramiek / IPS e.max',
inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' }, },
onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' }, veneer_full_contour: {
onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' }, en: 'Veneer · Full contour',
overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' }, fa: 'ونیر · تمام‌کانتور',
overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' }, nl: 'Veneer · Full contour',
cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' }, },
fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' }, veneer_layered: {
customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' }, en: 'Veneer · Layered ceramic',
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' }, fa: 'ونیر · سرامیک لایه‌ای',
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' }, nl: 'Veneer · Gelaagd keramiek',
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' }, },
zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' }, 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' }, screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' }, complete_denture: {
partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' }, en: 'Complete Denture',
fa: 'دنچر کامل',
nl: 'Volledige prothese',
},
partial_denture: {
en: 'Partial Denture',
fa: 'دنچر پارسیل',
nl: 'Partiële prothese',
},
overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' }, overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' },
night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' }, night_guard_soft: {
night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' }, en: 'Night Guard · Soft',
night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' }, 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' }, bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' },
clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' }, clear_aligner: {
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' }, en: 'Clear Aligner',
surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' }, 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' }, smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' }, mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' }, veneer_zirconia: {
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' }, en: 'Veneer Zirconia',
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' }, fa: 'ونیر زیرکونیا',
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' }, 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' }, ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
}; };
export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = { 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: { choosing_abutment: {
en: 'Choosing Abutment', en: 'Choosing Abutment',
fa: 'انتخاب اباتمنت', fa: 'انتخاب اباتمنت',
@@ -530,7 +722,11 @@ export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' }, build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
stain: { en: 'Stain', fa: 'رنگ‌آمیزی', nl: 'Kleuren' }, stain: { en: 'Stain', fa: 'رنگ‌آمیزی', nl: 'Kleuren' },
glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' }, 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' }, packing: { en: 'Packing', fa: 'بسته‌بندی', nl: 'Verpakken' },
shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' }, shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' }, pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
@@ -549,8 +745,58 @@ function labelsToTranslations(
return out; 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[] = [ export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS), ...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_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 TREATMENT_TYPE
PROSTHESIS_TYPE PROSTHESIS_TYPE
LAB_WORKFLOW_STEP LAB_WORKFLOW_STEP
PROSTHESIS_CATEGORY
PROSTHESIS_SUBCATEGORY
} }
model CatalogTranslation { model CatalogTranslation {

View File

@@ -62,6 +62,14 @@ export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
...FDI_LOWER_ARCH_ORDER, ...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 { export function isFdiTooth(value: unknown): value is string {
return typeof value === 'string' && FDI_TOOTH_IDS.has(value); return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
} }

View File

@@ -69,7 +69,9 @@ export class ProsthesisCatalogService implements OnModuleInit {
this.loaded = true; this.loaded = true;
} }
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> { async list(
localeInput?: string | null,
): Promise<ProsthesisTypeCatalogEntry[]> {
await this.refresh(); await this.refresh();
const locale = normalizeCatalogLocale(localeInput); const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()]; const codes = [...this.byCode.keys()];
@@ -92,17 +94,64 @@ export class ProsthesisCatalogService implements OnModuleInit {
stackGroup: row.stackGroup, 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 { assertKnownProsthesisType(code: string): void {
this.ensureLoaded(); this.ensureLoaded();
if (!this.byCode.has(code)) { 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({ const type = await this.prisma.prosthesisType.findUnique({
where: { code: prosthesisTypeCode }, where: { code: prosthesisTypeCode },
select: { select: {
@@ -120,7 +169,10 @@ export class ProsthesisCatalogService implements OnModuleInit {
return type.steps.map((s) => s.labWorkflowStep.code); 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( return this.catalogLabels.resolveLabels(
CatalogEntityKind.LAB_WORKFLOW_STEP, CatalogEntityKind.LAB_WORKFLOW_STEP,
stepCodes, stepCodes,

View File

@@ -8,14 +8,18 @@ const LOCALE_NOTES: Record<string, string> = {
'("دندون شماره ۲۶"). Digits may arrive in Persian or Latin script — either way, copy', '("دندون شماره ۲۶"). 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:', 'the number into "fdi" as two Latin digits. The descriptive form is quadrant-relative:',
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.', '"شش بالا راست" = 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(' '), ].join(' '),
nl: [ nl: [
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are', '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(' '), ].join(' '),
en: [ en: [
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are', '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(' '), ].join(' '),
}; };
@@ -24,6 +28,63 @@ function codeList(entries: { code: string; label: string }[]): string {
return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n'); 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( export function buildExtractionPrompt(
transcript: string, transcript: string,
catalog: ExtractionCatalog, catalog: ExtractionCatalog,
@@ -36,15 +97,15 @@ export function buildExtractionPrompt(
'You are a parser, not an assistant: report only what was said.', 'You are a parser, not an assistant: report only what was said.',
'', '',
'HARD RULES', 'HARD RULES',
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type', '1. Never invent a code. treatmentType and prosthesis[].types[] must be codes from the',
' must be codes from the lists below. labId must be an id from the lab list. If what you', ' lists below. labId must be an id from the lab list. If what you heard is not in a',
' heard is not in a list, use null.', ' 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', '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.", " flip to the viewer's point of view.",
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.', '3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
' If no deadline was mentioned, use due.kind = "none".', ' 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', '4. Copy the exact spoken words for each tooth, jaw and prosthesis instruction into',
' what was heard.', ' "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', '5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
' one is not.', ' one is not.',
'', '',
@@ -71,8 +132,24 @@ export function buildExtractionPrompt(
'TREATMENT TYPE CODES', 'TREATMENT TYPE CODES',
codeList(catalog.treatmentTypes), codeList(catalog.treatmentTypes),
'', '',
'PROSTHESIS TYPE CODES', 'PROSTHESIS WORK — prosthesis[]',
codeList(catalog.prosthesisTypes), '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', 'LABS THIS CLINIC CAN SEND TO',
catalog.labs.length > 0 catalog.labs.length > 0
@@ -82,7 +159,7 @@ export function buildExtractionPrompt(
'OTHER FIELDS', 'OTHER FIELDS',
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.', '- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
'- comment: clinical notes, in the language spoken. Omit the parts already captured as', '- 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.', '- labMatchExact: true only when the spoken name matched a lab name exactly.',
].join('\n'); ].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 { import {
resolveConnectedSpans, resolveConnectedSpans,
resolveProsthesis, resolveProsthesisAssignment,
resolveVoiceIntent, resolveVoiceIntent,
type ProsthesisLeaf,
type ResolveContext, type ResolveContext,
} from './extraction.resolver'; } 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 => ({ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
kind: 'explicit', kind: 'explicit',
@@ -12,12 +24,75 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
spoken, 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 = { const CTX: ResolveContext = {
todayIso: '2025-10-11', todayIso: '2025-10-11',
weekStartJs: 6, // Saturday — the fa week weekStartJs: 6, // Saturday — the fa week
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']), 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']), linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
}; };
@@ -111,122 +186,256 @@ describe('resolveConnectedSpans', () => {
}); });
}); });
describe('resolveProsthesis', () => { describe('resolveProsthesisAssignment', () => {
const allowed = CTX.prosthesisTypeCodes; function resolve(assignment: ProsthesisAssignment, index = 0) {
const unresolved: UnresolvedItem[] = [];
it('expands the default across every tooth', () => { const resolved = resolveProsthesisAssignment(
const result = resolveProsthesis( assignment,
{ defaultType: 'monolithic_zirconia', overrides: [] }, index,
['14', '15'], CTX,
allowed, unresolved,
); );
expect(result.prosthesis?.byTooth).toEqual({ return { resolved, unresolved };
'14': 'monolithic_zirconia', }
'15': 'monolithic_zirconia',
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', () => { it('resolves a stack — more than one type on the same target', () => {
const result = resolveProsthesis( const { resolved } = resolve({
{ targets: [tooth('12')],
defaultType: 'monolithic_zirconia', types: ['zirconia_abutment', 'monolithic_zirconia'],
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }], spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
},
['14', '26'],
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({
'14': 'monolithic_zirconia',
'26': 'pfm_crown',
}); });
expect(result.prosthesis?.complete).toBe(true); expect(resolved.targets).toEqual(['12']);
}); expect(resolved.types.sort()).toEqual([
'monolithic_zirconia',
it('marks the map incomplete when a tooth ends up untyped', () => { 'zirconia_abutment',
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
const result = resolveProsthesis(
{
defaultType: null,
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
},
['14', '15'],
allowed,
);
expect(result.prosthesis?.complete).toBe(false);
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
});
it('rejects a catalog code the clinic does not have', () => {
const result = resolveProsthesis(
{ defaultType: 'gold_foil', overrides: [] },
['14'],
allowed,
);
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
expect(result.prosthesis).toBeNull();
expect(result.unresolved).toEqual([
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
]); ]);
}); });
it('ignores an override for a tooth that is not selected', () => { it('resolves an arch target with no position to the jaw sentinel', () => {
const result = resolveProsthesis( const { resolved } = resolve({
{ targets: [positional({ arch: 'upper' })],
defaultType: 'monolithic_zirconia', types: ['night_guard_soft'],
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }], spoken: 'نایت گارد فک بالا',
}, });
['14'], expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
allowed,
);
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
expect(result.unresolved).toEqual([
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
]);
}); });
it('reports no prosthesis at all when the object carries nothing usable', () => { it('resolves "both jaws" to both sentinels', () => {
// An empty-but-present map would paint a plain restoration with a fabricated const { resolved } = resolve({
// "incomplete, cannot ship" warning. targets: [positional({ arch: 'both' })],
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) { types: ['night_guard_soft'],
expect( spoken: 'نایت گارد هر دو فک',
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis, });
).toBeNull(); 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', () => { it('reports a code not in the supplied catalog', () => {
const result = resolveProsthesis( const { resolved, unresolved } = resolve({
{ defaultType: 'monolithic_zirconia', overrides: [] }, targets: [tooth('12')],
[], types: ['gold_foil'],
allowed, spoken: '',
); });
expect(result.prosthesis).toBeNull(); expect(resolved.types).toEqual([]);
}); expect(unresolved).toEqual([
it('distinguishes a tooth it could not understand from one that is not selected', () => {
// Different corrective actions: add the tooth, versus repeat yourself.
const result = resolveProsthesis(
{ {
defaultType: 'monolithic_zirconia', spoken: 'gold_foil',
overrides: [ reason: 'unknown_catalog_code',
{ assignmentIndex: 0,
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
type: 'pfm_crown',
},
],
}, },
['14'],
allowed,
);
expect(result.unresolved).toEqual([
{ spoken: 'نود و نه', reason: 'malformed' },
]); ]);
}); });
it('returns null when no prosthesis was spoken', () => { it('rejects an arch code aimed at a tooth', () => {
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull(); 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', () => { describe('resolveVoiceIntent', () => {
@@ -235,7 +444,7 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('14'), tooth('15')], teeth: [tooth('14'), tooth('15')],
connectedSpans: [], connectedSpans: [],
comment: ' حساسیت به سرما ', comment: ' حساسیت به سرما ',
prosthesis: null, prosthesis: [],
labId: null, labId: null,
labMatchExact: false, labMatchExact: false,
due: null, due: null,
@@ -246,7 +455,7 @@ describe('resolveVoiceIntent', () => {
expect(result.treatmentType).toBe('restoration'); expect(result.treatmentType).toBe('restoration');
expect(result.teeth).toEqual(['14', '15']); expect(result.teeth).toEqual(['14', '15']);
expect(result.comment).toBe('حساسیت به سرما'); expect(result.comment).toBe('حساسیت به سرما');
expect(result.prosthesis).toBeNull(); expect(result.prosthesisAssignments).toEqual([]);
expect(result.unresolved).toEqual([]); expect(result.unresolved).toEqual([]);
}); });
@@ -299,27 +508,38 @@ describe('resolveVoiceIntent', () => {
expect(result.labMatchExact).toBe(false); 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( const result = resolveVoiceIntent(
{ {
...base, ...base,
treatmentType: 'prosthesis', treatmentType: 'restoration',
teeth: [tooth('14')], prosthesis: [
connectedSpans: [{ from: tooth('14'), to: tooth('16') }], {
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] }, targets: [tooth('12')],
types: ['pfm_crown'],
spoken: 'روکش برای ۱۲',
},
],
}, },
CTX, CTX,
); );
// 15 was never spoken but is part of the bridge, so it must carry a type too. expect(result.treatmentType).toBe('prosthesis');
expect(result.teeth).toEqual(['14', '15', '16']); expect(result.prosthesisAssignments).toEqual([
expect(result.prosthesis?.complete).toBe(true); { targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' },
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
'14',
'15',
'16',
]); ]);
}); });
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', () => { it('resolves a due date through the same context', () => {
const result = resolveVoiceIntent( const result = resolveVoiceIntent(
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } }, { ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
@@ -328,7 +548,7 @@ describe('resolveVoiceIntent', () => {
expect(result.dueDate).toBe('2025-10-16'); 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( const result = resolveVoiceIntent(
{ {
...base, ...base,
@@ -336,16 +556,34 @@ describe('resolveVoiceIntent', () => {
teeth: [tooth('51', 'شیری')], teeth: [tooth('51', 'شیری')],
connectedSpans: [{ from: tooth('14'), to: tooth('44') }], connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 }, due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
prosthesis: [
{
targets: [tooth('99', 'نود و نه')],
types: ['pfm_crown'],
spoken: '',
},
],
}, },
CTX, CTX,
); );
const reasons = result.unresolved.map((u) => u.reason).sort(); const reasons = result.unresolved.map((u) => u.reason).sort();
expect(reasons).toEqual([ expect(reasons).toEqual([
'invalid_date', 'invalid_date',
'malformed',
'not_permanent_tooth', 'not_permanent_tooth',
'span_not_same_arch', 'span_not_same_arch',
'unknown_catalog_code', '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', () => { it('treats an empty comment as absent', () => {

View File

@@ -1,7 +1,14 @@
import { import {
ARCH_TOOTH_LOWER,
ARCH_TOOTH_UPPER,
isFdiTooth,
normalizeFdiCode,
sameArch, sameArch,
sortInArchOrder, sortInArchOrder,
teethBetweenInclusive, teethBetweenInclusive,
toFdi,
type Arch,
type PatientSide,
} from '../../common/fdi'; } from '../../common/fdi';
import { resolveDueDate } from './due-date.resolver'; import { resolveDueDate } from './due-date.resolver';
import { import {
@@ -10,9 +17,10 @@ import {
} from './tooth-intent.resolver'; } from './tooth-intent.resolver';
import type { import type {
ConnectedSpanIntent, ConnectedSpanIntent,
ProsthesisIntent, ProsthesisAssignment,
ToothIntent, ToothIntent,
UnresolvedItem, UnresolvedItem,
UnresolvedReason,
VoiceIntent, VoiceIntent,
} from './voice.types'; } from './voice.types';
@@ -22,16 +30,16 @@ export type ResolvedToothGroup = {
teeth: string[]; teeth: string[];
}; };
export type ResolvedProsthesis = { /**
/** FDI code → prosthesis type code. */ * One spoken instruction, resolved: these targets (FDI codes, or `'UA'`/`'LA'` jaw sentinels —
byTooth: Record<string, string>; * 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
* True when every selected tooth carries a code. A prosthesis detail cannot be shipped * assignment still appears so its index keeps meaning for `UnresolvedItem.assignmentIndex`.
* otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap */
* here rather than letting it fail at dispatch. export type ResolvedProsthesisAssignment = {
*/ targets: string[];
complete: boolean; types: string[];
missingTeeth: string[]; spoken: string;
}; };
export type ResolvedExtraction = { export type ResolvedExtraction = {
@@ -39,23 +47,33 @@ export type ResolvedExtraction = {
teeth: string[]; teeth: string[];
toothSelectionGroups: ResolvedToothGroup[]; toothSelectionGroups: ResolvedToothGroup[];
comment: string | null; comment: string | null;
prosthesis: ResolvedProsthesis | null; prosthesisAssignments: ResolvedProsthesisAssignment[];
labId: string | null; labId: string | null;
labMatchExact: boolean; labMatchExact: boolean;
dueDate: string | null; dueDate: string | null;
unresolved: UnresolvedItem[]; 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 = { export type ResolveContext = {
todayIso: string; todayIso: string;
/** JS weekday index the clinician's week starts on — see weekStartForLocale. */ /** JS weekday index the clinician's week starts on — see weekStartForLocale. */
weekStartJs: number; weekStartJs: number;
treatmentTypeCodes: ReadonlySet<string>; treatmentTypeCodes: ReadonlySet<string>;
prosthesisTypeCodes: ReadonlySet<string>; prosthesisLeaves: readonly ProsthesisLeaf[];
prosthesisCategoryCodes: ReadonlySet<string>;
prosthesisSubcategoryCodes: ReadonlySet<string>;
linkedLabIds: ReadonlySet<string>; linkedLabIds: ReadonlySet<string>;
}; };
function spokenOf(intent: ToothIntent): string { function spokenOf(intent: unknown): string {
const spoken = (intent as { spoken?: unknown })?.spoken; const spoken = (intent as { spoken?: unknown })?.spoken;
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : ''; return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
} }
@@ -165,73 +183,259 @@ export function resolveConnectedSpans(
}; };
} }
// --- Prosthesis assignments -------------------------------------------------------------
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 default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is * A prosthesis assignment target is a tooth or a jaw — nothing on the wire declares which.
* how clinicians actually speak. * 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.
*/ */
export function resolveProsthesis( function resolveAssignmentTarget(target: ToothIntent): TargetResolution {
intent: ProsthesisIntent | null | undefined, if (!target || typeof target !== 'object') {
teeth: readonly string[], return { kind: 'unresolved', reason: 'malformed' };
allowed: ReadonlySet<string>,
): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } {
if (!intent || typeof intent !== 'object')
return { prosthesis: null, unresolved: [] };
const unresolved: UnresolvedItem[] = [];
const defaultType = resolveCatalogCode(intent.defaultType, allowed);
if (intent.defaultType != null && !defaultType) {
unresolved.push({
spoken: String(intent.defaultType),
reason: 'unknown_catalog_code',
});
} }
const byTooth: Record<string, string> = {}; if (target.kind === 'explicit') {
const selected = new Set(teeth); const fdi = normalizeFdiCode((target as { fdi?: unknown }).fdi);
if (defaultType) { if (isFdiTooth(fdi)) return { kind: 'tooth', fdi };
for (const tooth of teeth) byTooth[tooth] = defaultType; // 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',
};
} }
const overrides: ProsthesisIntent['overrides'] = Array.isArray( if (target.kind === 'positional') {
intent.overrides, const positionGiven =
) typeof target.position === 'number' && !Number.isNaN(target.position);
? intent.overrides 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: 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 rawTargets = Array.isArray(assignment?.targets)
? assignment.targets
: []; : [];
for (const override of overrides) { const targets = new Set<string>();
const tooth = resolveToothIntent(override?.tooth);
const type = resolveCatalogCode(override?.type, allowed); for (const rawTarget of rawTargets) {
const spoken = spokenOf(override?.tooth) || String(override?.type ?? ''); const resolution = resolveAssignmentTarget(rawTarget);
if (!tooth) { const spoken = spokenOf(rawTarget);
unresolved.push({ spoken, reason: 'malformed' });
if (resolution.kind === 'unresolved') {
unresolved.push(
resolution.candidates
? {
spoken,
reason: resolution.reason,
candidates: resolution.candidates,
assignmentIndex: index,
}
: { spoken, reason: resolution.reason, assignmentIndex: index },
);
continue; continue;
} }
// A tooth we understood perfectly well but which is not part of this detail. Saying
// so is actionable ("add tooth 37, or drop it"); calling it malformed is not. // Nothing to validate against yet — a bare category is deferred, and an empty types[]
if (!selected.has(tooth)) { // means the target is simply named with no job (left to the caller to render struck
unresolved.push({ spoken, reason: 'tooth_not_selected' }); // 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; continue;
} }
if (!type) {
unresolved.push({ spoken, reason: 'unknown_catalog_code' }); if (resolution.kind === 'tooth') {
continue; targets.add(resolution.fdi);
} else {
for (const sentinel of archSentinels(resolution.arch))
targets.add(sentinel);
} }
byTooth[tooth] = type;
} }
// Nothing usable was said about prosthesis. Returning an empty-but-present map would
// paint a plain restoration with a fabricated "incomplete, cannot ship" warning.
if (Object.keys(byTooth).length === 0) {
return { prosthesis: null, unresolved };
}
const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
return { return {
prosthesis: { targets: [...targets],
byTooth, types: [...leafCodes],
complete: missingTeeth.length === 0, spoken: spokenOf(assignment),
missingTeeth,
},
unresolved,
}; };
} }
@@ -262,12 +466,18 @@ export function resolveVoiceIntent(
}); });
} }
const prosthesisResult = resolveProsthesis( const rawAssignments = Array.isArray(intent?.prosthesis)
intent?.prosthesis, ? intent.prosthesis
spanResult.teeth, : [];
ctx.prosthesisTypeCodes, 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); const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
if (due.unresolved) unresolved.push(due.unresolved); if (due.unresolved) unresolved.push(due.unresolved);
@@ -287,11 +497,11 @@ export function resolveVoiceIntent(
} }
return { return {
treatmentType, treatmentType: hasResolvedAssignment ? 'prosthesis' : treatmentType,
teeth: spanResult.teeth, teeth: spanResult.teeth,
toothSelectionGroups: spanResult.groups, toothSelectionGroups: spanResult.groups,
comment, comment,
prosthesis: prosthesisResult.prosthesis, prosthesisAssignments,
labId, labId,
labMatchExact: labId ? intent?.labMatchExact === true : false, labMatchExact: labId ? intent?.labMatchExact === true : false,
dueDate: due.dueDate, dueDate: due.dueDate,

View File

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

View File

@@ -2,7 +2,7 @@ import { normalizeFdiCode } from '../../common/fdi';
import type { import type {
ConnectedSpanIntent, ConnectedSpanIntent,
DueIntent, DueIntent,
ProsthesisIntent, ProsthesisAssignment,
ToothIntent, ToothIntent,
VoiceIntent, VoiceIntent,
Weekday, Weekday,
@@ -20,7 +20,7 @@ export type WireToothIntent = {
spoken: string; spoken: string;
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */ /** The two-digit FDI code the clinician spoke; null when the tooth was described. */
fdi: string | null; fdi: string | null;
arch: 'upper' | 'lower' | null; arch: 'upper' | 'lower' | 'both' | null;
side: 'patient_right' | 'patient_left' | null; side: 'patient_right' | 'patient_left' | null;
position: number | null; position: number | null;
}; };
@@ -39,13 +39,18 @@ export type WireDue = {
d: number | null; d: number | null;
}; };
export type WireProsthesisAssignment = {
targets: WireToothIntent[];
types: string[];
spoken: string;
};
export type WireVoiceIntent = { export type WireVoiceIntent = {
treatmentType: string | null; treatmentType: string | null;
teeth: WireToothIntent[]; teeth: WireToothIntent[];
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[]; connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
comment: string | null; comment: string | null;
prosthesisDefaultType: string | null; prosthesis: WireProsthesisAssignment[];
prosthesisOverrides: { tooth: WireToothIntent; type: string }[];
labId: string | null; labId: string | null;
labMatchExact: boolean; labMatchExact: boolean;
due: WireDue; due: WireDue;
@@ -58,15 +63,21 @@ const TOOTH_SCHEMA = {
properties: { properties: {
spoken: { spoken: {
type: 'string', type: 'string',
description: 'The exact transcript words for this tooth.', description: 'The exact transcript words for this tooth (or jaw).',
}, },
fdi: { fdi: {
type: ['string', 'null'], type: ['string', 'null'],
description: description:
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' + '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: { side: {
type: ['string', 'null'], type: ['string', 'null'],
enum: ['patient_right', 'patient_left', null], enum: ['patient_right', 'patient_left', null],
@@ -75,7 +86,33 @@ const TOOTH_SCHEMA = {
position: { position: {
type: ['integer', 'null'], type: ['integer', 'null'],
description: 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; } as const;
@@ -88,8 +125,7 @@ export const VOICE_INTENT_JSON_SCHEMA = {
'teeth', 'teeth',
'connectedSpans', 'connectedSpans',
'comment', 'comment',
'prosthesisDefaultType', 'prosthesis',
'prosthesisOverrides',
'labId', 'labId',
'labMatchExact', 'labMatchExact',
'due', 'due',
@@ -114,19 +150,12 @@ export const VOICE_INTENT_JSON_SCHEMA = {
type: ['string', 'null'], type: ['string', 'null'],
description: 'Clinical notes, in the spoken language.', description: 'Clinical notes, in the spoken language.',
}, },
prosthesisDefaultType: { prosthesis: {
type: ['string', 'null'],
description:
'A prosthesis type CODE applied to every tooth unless overridden.',
},
prosthesisOverrides: {
type: 'array', type: 'array',
items: { description:
type: 'object', 'One entry per spoken instruction: these targets get these jobs. No default and no ' +
additionalProperties: false, 'overrides — every entry names its own targets.',
required: ['tooth', 'type'], items: PROSTHESIS_ASSIGNMENT_SCHEMA,
properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } },
},
}, },
labId: { labId: {
type: ['string', 'null'], type: ['string', 'null'],
@@ -190,7 +219,7 @@ function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
} }
return { return {
kind: 'positional', kind: 'positional',
arch: wire?.arch as 'upper' | 'lower', arch: wire?.arch as 'upper' | 'lower' | 'both',
side: wire?.side as 'patient_right' | 'patient_left', side: wire?.side as 'patient_right' | 'patient_left',
position: typeof wire?.position === 'number' ? wire.position : Number.NaN, position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
spoken, 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 { export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : []; const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : []; const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
const overrides = Array.isArray(wire?.prosthesisOverrides) const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : [];
? wire.prosthesisOverrides
: [];
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({ const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
from: toToothIntent(span?.from), from: toToothIntent(span?.from),
to: toToothIntent(span?.to), 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 { return {
treatmentType: wire?.treatmentType ?? null, treatmentType: wire?.treatmentType ?? null,
teeth: teeth.map(toToothIntent), teeth: teeth.map(toToothIntent),
connectedSpans, connectedSpans,
comment: wire?.comment ?? null, comment: wire?.comment ?? null,
prosthesis, prosthesis: assignments.map(toProsthesisAssignment),
labId: wire?.labId ?? null, labId: wire?.labId ?? null,
labMatchExact: wire?.labMatchExact === true, labMatchExact: wire?.labMatchExact === true,
due: toDueIntent(wire?.due), due: toDueIntent(wire?.due),

View File

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

View File

@@ -34,7 +34,22 @@ export interface AsrProvider {
export type ExtractionCatalog = { export type ExtractionCatalog = {
/** Catalog codes with their labels in the actor's locale, so the model matches spoken words. */ /** Catalog codes with their labels in the actor's locale, so the model matches spoken words. */
treatmentTypes: { code: string; label: string }[]; 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. */ /** The clinic's linked labs — a closed choice list. */
labs: { id: string; name: string }[]; labs: { id: string; name: string }[];
}; };

View File

@@ -138,8 +138,12 @@ export class VoiceService {
todayIso, todayIso,
weekStartJs: weekStartForLocale(catalogLocale), weekStartJs: weekStartForLocale(catalogLocale),
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)), treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
prosthesisTypeCodes: new Set( prosthesisLeaves: catalog.prosthesisTypes,
catalog.prosthesisTypes.map((t) => t.code), 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)), linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
}); });
@@ -244,9 +248,17 @@ export class VoiceService {
organizationId: string, organizationId: string,
locale: string, locale: string,
): Promise<ExtractionCatalog> { ): Promise<ExtractionCatalog> {
const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([ const [
treatmentTypes,
prosthesisTypes,
prosthesisCategories,
prosthesisSubcategories,
labs,
] = await Promise.all([
this.treatmentCatalog.list(locale, null), this.treatmentCatalog.list(locale, null),
this.prosthesisCatalog.list(locale), this.prosthesisCatalog.list(locale),
this.prosthesisCatalog.listCategories(locale),
this.prosthesisCatalog.listSubcategories(locale),
this.listLinkedLabs(organizationId), this.listLinkedLabs(organizationId),
]); ]);
@@ -254,10 +266,11 @@ export class VoiceService {
treatmentTypes: treatmentTypes treatmentTypes: treatmentTypes
.filter((entry) => entry.availableInTreatment) .filter((entry) => entry.availableInTreatment)
.map((entry) => ({ code: entry.code, label: entry.label })), .map((entry) => ({ code: entry.code, label: entry.label })),
prosthesisTypes: prosthesisTypes.map((entry) => ({ // `buildCatalog` used to throw away category/subcategory/chartRegion/stackGroup here —
code: entry.code, // the prompt now presents the catalog as the tree it is (§5).
label: entry.label, prosthesisTypes,
})), prosthesisCategories,
prosthesisSubcategories,
labs, labs,
}; };
} }
@@ -333,7 +346,7 @@ export class VoiceService {
treatmentType: resolved.treatmentType != null, treatmentType: resolved.treatmentType != null,
teeth: resolved.teeth.length, teeth: resolved.teeth.length,
comment: resolved.comment != null, comment: resolved.comment != null,
prosthesisComplete: resolved.prosthesis?.complete ?? null, prosthesisAssignments: resolved.prosthesisAssignments.length,
lab: resolved.labId != null, lab: resolved.labId != null,
dueDate: resolved.dueDate != 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. * 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 = export type ToothIntent =
| { kind: 'explicit'; fdi: string; spoken: string } | { kind: 'explicit'; fdi: string; spoken: string }
| { | {
kind: 'positional'; kind: 'positional';
arch: Arch; arch: Arch | 'both';
side: PatientSide; side: PatientSide;
/** 1 = central incisor … 8 = third molar. */ /** 1 = central incisor … 8 = third molar. Absent (NaN) when the target is a jaw. */
position: number; position: number;
spoken: string; spoken: string;
}; };
@@ -42,10 +47,18 @@ export type Weekday = (typeof WEEKDAYS)[number];
/** Two teeth defining an inclusive connected (bridge) span. */ /** Two teeth defining an inclusive connected (bridge) span. */
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent }; export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
export type ProsthesisIntent = { /**
/** Catalog code applied to every tooth unless overridden. */ * One spoken instruction: these targets get these jobs. Replaces the old
defaultType: string | null; * `prosthesisDefaultType` + `prosthesisOverrides` pair — a default with per-tooth overrides
overrides: { tooth: ToothIntent; type: string }[]; * 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 = { export type VoiceIntent = {
@@ -53,7 +66,8 @@ export type VoiceIntent = {
teeth: ToothIntent[]; teeth: ToothIntent[];
connectedSpans: ConnectedSpanIntent[]; connectedSpans: ConnectedSpanIntent[];
comment: string | null; 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. */ /** Must be one of the linked-lab ids supplied in the prompt, or null. */
labId: string | null; labId: string | null;
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */ /** 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' | 'position_out_of_range'
/** A position was understood but no quadrant was spoken — four teeth match. */ /** A position was understood but no quadrant was spoken — four teeth match. */
| 'tooth_missing_quadrant' | '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' | 'malformed'
| 'span_not_same_arch' | 'span_not_same_arch'
| 'unknown_catalog_code' | 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date'; | 'invalid_date';
export type UnresolvedItem = { export type UnresolvedItem = {
@@ -78,8 +97,15 @@ export type UnresolvedItem = {
spoken: string; spoken: string;
reason: UnresolvedReason; reason: UnresolvedReason;
/** /**
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only * Values still consistent with what was heard: FDI codes for `tooth_missing_quadrant`,
* `tooth_missing_quadrant` carries them; the sheet offers them as chips. * leaf codes for `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`. The
* sheet offers these as chips.
*/ */
candidates?: string[]; 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 | | # | Work item | Repo | Status | Notes / refs |
|---|-----------|------|--------|--------------| |---|-----------|------|--------|--------------|
| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | | §9. The Safari failure | | 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` | | 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 | | 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` already receives all four | | 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` | | 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` | | 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 | | 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. Only `prosthesis` is `labDependent` | | 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` | | 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 | | 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` | | 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 | | 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. `crownColors` / `rootColors` / `archHighlight` already exist | | 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 | | 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 | | 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 | | 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` | | §12 | | 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. **Must land before item 9** | | 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. Without it the quadrant chip is dead on a prosthesis detail | | 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 ## 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 | | 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. 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…", "voiceProcessing": "Reading the recording…",
"voiceReviewTitle": "Check what was understood", "voiceReviewTitle": "Check what was understood",
"voiceNothingExtracted": "Nothing usable was picked up from that recording.", "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.", "voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
"voiceNotUnderstood": "Not understood", "voiceNotUnderstood": "Not understood",
"voicePickTooth": "Which tooth?", "voicePickTooth": "Which tooth?",
"voicePickJaw": "Which jaw?",
"voiceTeethAndProsthesis": "Teeth and prosthesis",
"voiceNoProsthesisHeard": "no prosthesis heard",
"voiceStackRefused": "not added — cannot be combined",
"voiceDiscard": "Discard", "voiceDiscard": "Discard",
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}", "voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
"voiceUnresolved": { "voiceUnresolved": {
"not_permanent_tooth": "not a permanent tooth", "not_permanent_tooth": "not a permanent tooth",
"position_out_of_range": "not a valid tooth position", "position_out_of_range": "not a valid tooth position",
"tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”", "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", "malformed": "could not be read",
"span_not_same_arch": "a bridge cannot span both jaws", "span_not_same_arch": "a bridge cannot span both jaws",
"unknown_catalog_code": "not in this clinics list", "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" "invalid_date": "not a usable date"
}, },
"voiceFailed": "Voice entry failed. Please try again." "voiceFailed": "Voice entry failed. Please try again."

View File

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

View File

@@ -926,20 +926,25 @@
"voiceProcessing": "Opname wordt gelezen…", "voiceProcessing": "Opname wordt gelezen…",
"voiceReviewTitle": "Controleer wat is begrepen", "voiceReviewTitle": "Controleer wat is begrepen",
"voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.", "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.", "voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
"voiceNotUnderstood": "Niet begrepen", "voiceNotUnderstood": "Niet begrepen",
"voicePickTooth": "Welk element?", "voicePickTooth": "Welk element?",
"voicePickJaw": "Welke kaak?",
"voiceTeethAndProsthesis": "Elementen en prothese",
"voiceNoProsthesisHeard": "geen prothese gehoord",
"voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd",
"voiceDiscard": "Verwerpen", "voiceDiscard": "Verwerpen",
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}", "voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
"voiceUnresolved": { "voiceUnresolved": {
"not_permanent_tooth": "geen blijvend element", "not_permanent_tooth": "geen blijvend element",
"position_out_of_range": "geen geldige elementpositie", "position_out_of_range": "geen geldige elementpositie",
"tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”", "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", "malformed": "kon niet worden gelezen",
"span_not_same_arch": "een brug kan niet over beide kaken lopen", "span_not_same_arch": "een brug kan niet over beide kaken lopen",
"unknown_catalog_code": "staat niet in de lijst van deze praktijk", "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" "invalid_date": "geen bruikbare datum"
}, },
"voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw." "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", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start -p 3001", "start": "next start -p 3001",
"lint": "next lint" "lint": "next lint",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
@@ -36,6 +37,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"tailwindcss": "^4", "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 { 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 { FdiToothId } from '@/types/treatment';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { import type {
VoiceApplySelection, VoiceApplySelection,
VoiceExtractionResult, VoiceExtractionResult,
VoiceProsthesisResult, VoiceProsthesisAssignment,
VoiceUnresolvedItem,
} from '@/types/voice'; } from '@/types/voice';
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */ /** `prosthesis` is the app's only labDependent treatment type today, but this stays generic. */
export function voiceRowAvailability(result: VoiceExtractionResult) { 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 { return {
treatmentType: result.treatmentType != null, 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()), comment: Boolean(result.comment?.trim()),
prosthesis: result.prosthesis != null,
lab: result.labId != null, lab: result.labId != null,
dueDate: result.dueDate != null, dueDate: result.dueDate != null,
}; };
} }
/** /**
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because * Everything available ticks itself, with one exception: an inexactly-matched lab, because it
* it is the one extracted value whose error leaves the building; and an incomplete * is the one extracted value whose error leaves the building. All-or-nothing prosthesis maps
* prosthesis map, which cannot ship at all and would just move the failure to dispatch. * are retired (decision 40) — an incomplete stack no longer blocks a tick.
*/ */
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection { export function initialVoiceSelection(
const available = voiceRowAvailability(result); result: VoiceExtractionResult,
labDependentCodes: ReadonlySet<string>,
): VoiceApplySelection {
const available = voiceRowAvailability(result, labDependentCodes);
return { return {
treatmentType: available.treatmentType, treatmentType: available.treatmentType,
teeth: available.teeth, teeth: available.teeth,
comment: available.comment, comment: available.comment,
prosthesis: available.prosthesis && result.prosthesis?.complete === true, prosthesis: available.prosthesis,
lab: available.lab && result.labMatchExact, lab: available.lab && result.labMatchExact,
dueDate: available.dueDate, dueDate: available.dueDate,
}; };
@@ -48,37 +86,75 @@ export function countSelected(
).length; ).length;
} }
/** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */ /** Every resolved assignment target, across every assignment. */
function recheckProsthesis( function allAssignmentTargets(result: VoiceExtractionResult): Set<string> {
prosthesis: VoiceProsthesisResult, return new Set(result.prosthesisAssignments.flatMap((a) => a.targets));
teeth: readonly FdiToothId[], }
): VoiceProsthesisResult {
const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]); /** Forces treatmentType to `prosthesis` the moment any assignment carries a real target. */
return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 }; 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 * 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( export function withChosenTeeth(
result: VoiceExtractionResult, result: VoiceExtractionResult,
chosen: readonly FdiToothId[], chosen: readonly FdiToothId[],
assignmentIndex?: number,
): VoiceExtractionResult { ): VoiceExtractionResult {
if (chosen.length === 0) return result; 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 { return {
...result, ...result,
teeth, teeth,
toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups), 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. */ /** Teeth that are part of a bridge, for the read-only chart's connection marks. */
export function connectedTeethFromResult(result: VoiceExtractionResult): Set<FdiToothId> { export function connectedTeethFromResult(result: VoiceExtractionResult): Set<FdiToothId> {
const connected = new Set<FdiToothId>(); const connected = new Set<FdiToothId>();
@@ -89,7 +165,138 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
return connected; return connected;
} }
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */ export type VoiceProsthesisTargetLine = {
export function hasAnythingToApply(result: VoiceExtractionResult): boolean { target: string;
return Object.values(voiceRowAvailability(result)).some(Boolean); 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 { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { voiceApi } from '@/lib/api/voice'; import { voiceApi } from '@/lib/api/voice';
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture'; import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
import { isMediaRecorderSupported } from '@/lib/voice/audioFormat';
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet'; import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
import type { import type {
VoiceApplySelection, VoiceApplySelection,
@@ -61,6 +62,10 @@ import {
unlinkAdjacentTeeth, unlinkAdjacentTeeth,
} from '@/components/treatment/toothSelectionGroups'; } from '@/components/treatment/toothSelectionGroups';
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree'; import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
import {
isLabDependentResult,
prosthesisTargetLines,
} from '@/components/treatment/voiceReviewRows';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { import {
@@ -621,9 +626,18 @@ export function TreatmentWorkspace({
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))), 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 = const voiceForEditor =
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined; voiceAvailability?.enabled &&
voiceAvailability.locales.includes(locale) &&
isMediaRecorderSupported()
? voice
: undefined;
const selectedStandalone = useMemo( const selectedStandalone = useMemo(
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null, () => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
@@ -2192,7 +2206,13 @@ export function TreatmentWorkspace({
if (selection.treatmentType && result.treatmentType) { if (selection.treatmentType && result.treatmentType) {
detail.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.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({ detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group, ...group,
@@ -2203,6 +2223,25 @@ export function TreatmentWorkspace({
detail.comment = result.comment; 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]; const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails); setDetails(nextDetails);
// persistDraft reads detailsRef, and setDetails has not rendered yet. // 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 // 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. // brand-new unsaved detail can still carry one; it is persisted after the detail is.
const wantsLabDraft = const wantsLabDraft =
(selection.prosthesis && result.prosthesis) || prosthesisLines.some((line) => line.applied.length > 0) ||
(selection.lab && result.labId) || (selection.lab && result.labId) ||
(selection.dueDate && result.dueDate); (selection.dueDate && result.dueDate);
@@ -2225,26 +2264,19 @@ export function TreatmentWorkspace({
if (selection.dueDate && result.dueDate) { if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate; draft.dueDate = result.dueDate;
} }
if (selection.prosthesis && result.prosthesis) { draft.toothProsthesis = prosthesisLines.flatMap((line) => {
// byTooth keys are plain strings; the group's teeth are FdiToothId. const groupId = line.isJaw
const groupOf = (tooth: string) => ? ''
result.toothSelectionGroups.find((group) => : (detail.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(tooth), (group.teeth as readonly string[]).includes(line.target),
)?.groupId ?? ''; )?.groupId ?? '');
// Only teeth that actually landed on the detail. Unticking "teeth" while return line.applied.map((prosthesisTypeCode) => ({
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth detailClientId: detail.clientId,
// the treatment does not contain — nothing downstream filters them, and they tooth: line.target,
// would reach task generation as work for teeth nobody is treating. prosthesisTypeCode,
const detailTeeth = new Set<string>(detail.teeth); selectionGroupId: groupId,
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth) }));
.filter(([tooth]) => detailTeeth.has(tooth)) });
.map(([tooth, prosthesisTypeCode]) => ({
detailClientId: detail.clientId,
tooth,
prosthesisTypeCode,
selectionGroupId: groupOf(tooth),
}));
}
const updatedLabCases = [...labCaseDrafts, draft]; const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases); setLabCaseDrafts(updatedLabCases);
@@ -2269,8 +2301,10 @@ export function TreatmentWorkspace({
}, },
[ [
labCaseDrafts, labCaseDrafts,
labDependentCodes,
persistDraft, persistDraft,
persistLabCases, persistLabCases,
prosthesisCatalog,
selectedAppointment?.purpose, selectedAppointment?.purpose,
showError, showError,
t, t,
@@ -3202,6 +3236,7 @@ export function TreatmentWorkspace({
result={voiceResult} result={voiceResult}
treatmentCatalog={treatmentCatalog} treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog} prosthesisCatalog={prosthesisCatalog}
labDependentCodes={labDependentCodes}
labs={orgs} labs={orgs}
onApply={(selection, applied) => applyVoiceResult(applied, selection)} onApply={(selection, applied) => applyVoiceResult(applied, selection)}
onDiscard={() => setVoiceResult(null)} onDiscard={() => setVoiceResult(null)}

View File

@@ -10,31 +10,44 @@ import {
ResponsiveDialogPanel, ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog'; } from '@/components/ui/shared/ResponsiveDialog';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '@/components/treatment/prosthesisTree';
import { import {
connectedTeethFromResult, connectedTeethFromResult,
countSelected, countSelected,
hasAnythingToApply, hasAnythingToApply,
initialVoiceSelection, initialVoiceSelection,
joblessProsthesisTargets,
prosthesisChartData,
prosthesisTargetLines,
voiceRowAvailability, voiceRowAvailability,
withChosenArch,
withChosenProsthesisLeaf,
withChosenTeeth, withChosenTeeth,
} from '@/components/treatment/voiceReviewRows'; } from '@/components/treatment/voiceReviewRows';
import { useLocale } from 'next-intl';
import { useAppFormatters } from '@/lib/hooks/useAppFormatters'; import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment'; import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceUnresolvedItem,
} from '@/types/voice';
interface VoiceReviewSheetProps { interface VoiceReviewSheetProps {
result: VoiceExtractionResult; result: VoiceExtractionResult;
treatmentCatalog: TreatmentCatalogEntry[]; treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog: ProsthesisCatalogEntry[]; prosthesisCatalog: ProsthesisCatalogEntry[];
/** `prosthesis` today, but kept generic — the same set the workspace already tracks. */
labDependentCodes: ReadonlySet<string>;
labs: LinkedOrganizationOption[]; 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; onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
onDiscard: () => void; onDiscard: () => void;
} }
type ArchPick = 'upper' | 'lower';
/** /**
* Confirmation step between the model's output and the form. * Confirmation step between the model's output and the form.
* *
@@ -45,50 +58,131 @@ export function VoiceReviewSheet({
result, result,
treatmentCatalog, treatmentCatalog,
prosthesisCatalog, prosthesisCatalog,
labDependentCodes,
labs, labs,
onApply, onApply,
onDiscard, onDiscard,
}: VoiceReviewSheetProps) { }: VoiceReviewSheetProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
const locale = useLocale();
const { formatDate } = useAppFormatters(); const { formatDate } = useAppFormatters();
const [selection, setSelection] = useState<VoiceApplySelection>(() => 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 // Every kind of candidate chip the sheet can offer, tracked separately because each folds
// the candidate chips has to reach the rows, the chart and the apply count alike. // into the result a different way (decision 50). Toggling off removes only the clinician's
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]); // 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 connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]); 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 selectedCount = countSelected(selection, available);
const pickCandidate = (tooth: FdiToothId) => { const targetLabel = (target: string): string => {
const nextChosen = chosen.includes(tooth) if (target === ARCH_TOOTH_UPPER) return t('upperArch');
? chosen.filter((t) => t !== tooth) if (target === ARCH_TOOTH_LOWER) return t('lowerArch');
: [...chosen, tooth]; return target;
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 labelFor = (code: string | null, catalog: { code: string; label: string }[]) => const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
catalog.find((entry) => entry.code === code)?.label ?? code ?? ''; 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) => const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
setSelection((prev) => ({ ...prev, [key]: checked })); setSelection((prev) => ({ ...prev, [key]: checked }));
@@ -138,6 +232,59 @@ export function VoiceReviewSheet({
</Row> </Row>
) : null} ) : 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 ? ( {available.comment ? (
<Row <Row
label={t('comments')} label={t('comments')}
@@ -150,29 +297,6 @@ export function VoiceReviewSheet({
</Row> </Row>
) : null} ) : 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 ? ( {available.lab ? (
<Row <Row
label={t('entryStepLab')} label={t('entryStepLab')}
@@ -212,23 +336,29 @@ export function VoiceReviewSheet({
{t(`voiceUnresolved.${item.reason}`)} {t(`voiceUnresolved.${item.reason}`)}
{item.candidates && item.candidates.length > 0 ? ( {item.candidates && item.candidates.length > 0 ? (
<span className="mt-1 flex flex-wrap items-center gap-1"> <span className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-text-muted">{t('voicePickTooth')}</span> {promptFor(item) ? (
{item.candidates.map((tooth) => { <span className="text-text-muted">{promptFor(item)}</span>
const picked = chosen.includes(tooth as FdiToothId); ) : null}
{item.candidates.map((code) => {
const picked = isPicked(item, code);
return ( return (
<button <button
key={tooth} key={code}
type="button" type="button"
aria-pressed={picked} aria-pressed={picked}
aria-label={t('toothAria', { fdi: tooth })} aria-label={
onClick={() => pickCandidate(tooth as FdiToothId)} 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 ${ className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
picked picked
? 'border-transparent bg-primary text-white' ? 'border-transparent bg-primary text-white'
: 'border-border text-text-primary hover:border-border-strong' : 'border-border text-text-primary hover:border-border-strong'
}`} }`}
> >
{tooth} {candidateLabel(item, code)}
</button> </button>
); );
})} })}
@@ -295,12 +425,3 @@ function civilDateToLocalDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number); const [year, month, day] = iso.split('-').map(Number);
return new Date(year, (month ?? 1) - 1, day ?? 1); 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) { for (const type of PREFERRED_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(type)) return type; 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. */ /** `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' | 'not_permanent_tooth'
| 'position_out_of_range' | 'position_out_of_range'
| 'tooth_missing_quadrant' | 'tooth_missing_quadrant'
| 'prosthesis_type_ambiguous'
| 'arch_not_spoken'
| 'code_not_valid_for_target'
| 'malformed' | 'malformed'
| 'span_not_same_arch' | 'span_not_same_arch'
| 'unknown_catalog_code' | 'unknown_catalog_code'
| 'tooth_not_selected'
| 'invalid_date'; | 'invalid_date';
export interface VoiceUnresolvedItem { export interface VoiceUnresolvedItem {
@@ -17,17 +19,30 @@ export interface VoiceUnresolvedItem {
spoken: string; spoken: string;
reason: VoiceUnresolvedReason; reason: VoiceUnresolvedReason;
/** /**
* FDI codes still consistent with what was heard, when a choice would settle it — the * Values still consistent with what was heard, when a choice would settle it — the review
* review sheet offers them as chips. Only `tooth_missing_quadrant` carries these. * 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[]; 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>; * One spoken instruction, resolved: these targets — FDI codes, or `'UA'`/`'LA'` jaw sentinels
/** False means the case cannot ship — every tooth needs a prosthesis type. */ * (`ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` in `prosthesisTree.ts`) — get these leaf codes. Empty
complete: boolean; * `types` means the target was named with no job at all (struck through in the sheet); empty
missingTeeth: FdiToothId[]; * `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 { export interface VoiceExtractionResult {
@@ -36,7 +51,7 @@ export interface VoiceExtractionResult {
teeth: FdiToothId[]; teeth: FdiToothId[];
toothSelectionGroups: ToothSelectionGroup[]; toothSelectionGroups: ToothSelectionGroup[];
comment: string | null; comment: string | null;
prosthesis: VoiceProsthesisResult | null; prosthesisAssignments: VoiceProsthesisAssignment[];
labId: string | null; labId: string | null;
/** When false, the lab row must not tick itself — the name only approximately matched. */ /** When false, the lab row must not tick itself — the name only approximately matched. */
labMatchExact: boolean; 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'],
},
});