Compare commits
14 Commits
improvemen
...
feat/treat
| Author | SHA1 | Date | |
|---|---|---|---|
| d8c9fa8995 | |||
| 10408058c4 | |||
| 46c8a25139 | |||
| dc23c3dd1c | |||
| 76d929de3a | |||
| fbe423bc95 | |||
| dd8e6b17e5 | |||
| c1d3a241a5 | |||
| ebfc8af87c | |||
| f1a4594a0a | |||
| e271858a33 | |||
| 15ddb9aac2 | |||
| 77e2ed4b42 | |||
| 224663a481 |
@@ -39,6 +39,7 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or
|
||||
| `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
|
||||
| `npm run build` | Production build (`output: 'standalone'`) |
|
||||
| `npm run lint` | ESLint via Next |
|
||||
| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts`) |
|
||||
|
||||
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
|
||||
|
||||
@@ -102,7 +103,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
|
||||
|
||||
### Tests
|
||||
|
||||
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation (7 suites in `backend/src/**`). There are no frontend tests; `npx tsc --noEmit` is the frontend gate.
|
||||
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts`, `voiceReviewRows.ts` and `toothSelectionGroups.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. `npx tsc --noEmit` remains the frontend's cross-cutting gate.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -71,7 +71,11 @@ export const LAB_WORKFLOW_STEPS = [
|
||||
] as const;
|
||||
|
||||
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
|
||||
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
|
||||
export type ProsthesisStackGroup =
|
||||
| 'restoration'
|
||||
| 'implant'
|
||||
| 'post_core'
|
||||
| 'arch';
|
||||
|
||||
export type ProsthesisTypeSeed = {
|
||||
code: string;
|
||||
@@ -87,7 +91,13 @@ export type ProsthesisTypeSeed = {
|
||||
};
|
||||
|
||||
const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const;
|
||||
const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const;
|
||||
const INDIRECT_FULL = [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_wet',
|
||||
'sinter',
|
||||
'stain',
|
||||
'glaze',
|
||||
] as const;
|
||||
const INDIRECT_LAYERED = [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_wet',
|
||||
@@ -115,9 +125,24 @@ const KEEP_LAYERED = [
|
||||
'glaze',
|
||||
'polish_prep',
|
||||
] as const;
|
||||
const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
|
||||
const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
|
||||
const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const;
|
||||
const DENTURE = [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'printer_resin',
|
||||
'polish_prep',
|
||||
] as const;
|
||||
const APPLIANCE = [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'printer_resin',
|
||||
'polish_prep',
|
||||
] as const;
|
||||
const POST_CORE = [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'milling_wet',
|
||||
'polish_prep',
|
||||
] as const;
|
||||
|
||||
function crown(
|
||||
code: string,
|
||||
@@ -165,7 +190,8 @@ function indirect(
|
||||
subcategory: indication,
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
|
||||
manufacturingSteps:
|
||||
technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,10 +226,24 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
'stain',
|
||||
'glaze',
|
||||
]),
|
||||
crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']),
|
||||
crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']),
|
||||
crown('full_metal_crown', 5, [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_wet',
|
||||
'polish_prep',
|
||||
]),
|
||||
crown('temporary_resin_crown', 6, [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'milling_wet',
|
||||
'polish_prep',
|
||||
]),
|
||||
crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
|
||||
crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
|
||||
crown('peek_crown', 8, [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
'milling_dry',
|
||||
'polish_prep',
|
||||
]),
|
||||
crown('press_ceramic', 9, [
|
||||
'intraoral_scan',
|
||||
'design',
|
||||
@@ -243,7 +283,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
'model_create',
|
||||
'polish_prep',
|
||||
]),
|
||||
implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
|
||||
implant('ti_base_abutment', 41, [
|
||||
'intraoral_scan',
|
||||
'model_create',
|
||||
'polish_prep',
|
||||
]),
|
||||
implant('multi_unit_abutment', 42, [
|
||||
'intraoral_scan',
|
||||
'choosing_abutment',
|
||||
@@ -255,7 +299,11 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
'milling_wet',
|
||||
'polish_prep',
|
||||
]),
|
||||
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
|
||||
implant('zirconia_abutment', 44, [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_dry',
|
||||
'sinter',
|
||||
]),
|
||||
implant(
|
||||
'screw_retained',
|
||||
45,
|
||||
@@ -450,7 +498,11 @@ export {
|
||||
|
||||
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
|
||||
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
|
||||
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
|
||||
specialized_restoration: {
|
||||
en: 'Specialized Restoration',
|
||||
fa: 'ترمیم تخصصی',
|
||||
nl: 'Gespecialiseerde Restauratie',
|
||||
},
|
||||
radiography: { en: 'Radiography', fa: 'رادیوگرافی', nl: 'Röntgen' },
|
||||
endo: { en: 'Endo', fa: 'اندو', nl: 'Endo' },
|
||||
surgery: { en: 'Surgery', fa: 'جراحی', nl: 'Chirurgie' },
|
||||
@@ -460,8 +512,16 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
|
||||
perio: { en: 'Perio', fa: 'پریو', nl: 'Paro' },
|
||||
pediatrics: { en: 'Pediatrics', fa: 'اطفال', nl: 'Kinderen' },
|
||||
extraction: { en: 'Extraction', fa: 'کشیدن', nl: 'Extractie' },
|
||||
clinic_visit: { en: 'Clinic Visit', fa: 'ویزیت درمانگاه', nl: 'Kliniekbezoek' },
|
||||
continue_treatment: { en: 'Continue Treatment', fa: 'ادامه درمان', nl: 'Behandeling Voortzetten' },
|
||||
clinic_visit: {
|
||||
en: 'Clinic Visit',
|
||||
fa: 'ویزیت درمانگاه',
|
||||
nl: 'Kliniekbezoek',
|
||||
},
|
||||
continue_treatment: {
|
||||
en: 'Continue Treatment',
|
||||
fa: 'ادامه درمان',
|
||||
nl: 'Behandeling Voortzetten',
|
||||
},
|
||||
consultation: { en: 'Consultation', fa: 'مشاوره', nl: 'Consult' },
|
||||
filling: { en: 'Filling', fa: 'پر کردن', nl: 'Vulling' },
|
||||
visit: { en: 'Visit', fa: 'ویزیت', nl: 'Bezoek' },
|
||||
@@ -471,50 +531,182 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
|
||||
export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
|
||||
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
|
||||
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
|
||||
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
|
||||
glass_ceramic_crown: { en: 'Glass Ceramic Crown', fa: 'روکش سرامیک شیشهای', nl: 'Glaskeramische Kroon' },
|
||||
full_metal_crown: { en: 'Full Metal Crown', fa: 'روکش تمام فلز', nl: 'Volledige Metalen Kroon' },
|
||||
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
|
||||
monolithic_zirconia: {
|
||||
en: 'Monolithic Zirconia',
|
||||
fa: 'زیرکونیا مونولیتیک',
|
||||
nl: 'Monolithisch Zirconia',
|
||||
},
|
||||
glass_ceramic_crown: {
|
||||
en: 'Glass Ceramic Crown',
|
||||
fa: 'روکش سرامیک شیشهای',
|
||||
nl: 'Glaskeramische Kroon',
|
||||
},
|
||||
full_metal_crown: {
|
||||
en: 'Full Metal Crown',
|
||||
fa: 'روکش تمام فلز',
|
||||
nl: 'Volledige Metalen Kroon',
|
||||
},
|
||||
temporary_resin_crown: {
|
||||
en: 'Temporary Resin Crown',
|
||||
fa: 'روکش موقت رزینی',
|
||||
nl: 'Tijdelijke Harskroon',
|
||||
},
|
||||
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
|
||||
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
|
||||
press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' },
|
||||
veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمامکانتور', nl: 'Veneer · Full contour' },
|
||||
veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایهای', nl: 'Veneer · Gelaagd keramiek' },
|
||||
inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمامکانتور', nl: 'Inlay · Full contour' },
|
||||
inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایهای', nl: 'Inlay · Gelaagd keramiek' },
|
||||
onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمامکانتور', nl: 'Onlay · Full contour' },
|
||||
onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایهای', nl: 'Onlay · Gelaagd keramiek' },
|
||||
overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمامکانتور', nl: 'Overlay · Full contour' },
|
||||
overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایهای', nl: 'Overlay · Gelaagd keramiek' },
|
||||
cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' },
|
||||
fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' },
|
||||
customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' },
|
||||
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
|
||||
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
|
||||
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
|
||||
zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' },
|
||||
press_ceramic: {
|
||||
en: 'Pressed Ceramic / IPS e.max',
|
||||
fa: 'سرامیک پرس / IPS e.max',
|
||||
nl: 'Perskeramiek / IPS e.max',
|
||||
},
|
||||
veneer_full_contour: {
|
||||
en: 'Veneer · Full contour',
|
||||
fa: 'ونیر · تمامکانتور',
|
||||
nl: 'Veneer · Full contour',
|
||||
},
|
||||
veneer_layered: {
|
||||
en: 'Veneer · Layered ceramic',
|
||||
fa: 'ونیر · سرامیک لایهای',
|
||||
nl: 'Veneer · Gelaagd keramiek',
|
||||
},
|
||||
inlay_full_contour: {
|
||||
en: 'Inlay · Full contour',
|
||||
fa: 'اینلی · تمامکانتور',
|
||||
nl: 'Inlay · Full contour',
|
||||
},
|
||||
inlay_layered: {
|
||||
en: 'Inlay · Layered ceramic',
|
||||
fa: 'اینلی · سرامیک لایهای',
|
||||
nl: 'Inlay · Gelaagd keramiek',
|
||||
},
|
||||
onlay_full_contour: {
|
||||
en: 'Onlay · Full contour',
|
||||
fa: 'آنلی · تمامکانتور',
|
||||
nl: 'Onlay · Full contour',
|
||||
},
|
||||
onlay_layered: {
|
||||
en: 'Onlay · Layered ceramic',
|
||||
fa: 'آنلی · سرامیک لایهای',
|
||||
nl: 'Onlay · Gelaagd keramiek',
|
||||
},
|
||||
overlay_full_contour: {
|
||||
en: 'Overlay · Full contour',
|
||||
fa: 'اورلی · تمامکانتور',
|
||||
nl: 'Overlay · Full contour',
|
||||
},
|
||||
overlay_layered: {
|
||||
en: 'Overlay · Layered ceramic',
|
||||
fa: 'اورلی · سرامیک لایهای',
|
||||
nl: 'Overlay · Gelaagd keramiek',
|
||||
},
|
||||
cast_post_core: {
|
||||
en: 'Cast Post & Core',
|
||||
fa: 'پست و کور ریختگی',
|
||||
nl: 'Gegoten Stiftopbouw',
|
||||
},
|
||||
fiber_post_core: {
|
||||
en: 'Fiber Post & Core',
|
||||
fa: 'پست و کور فایبر',
|
||||
nl: 'Fiber Stiftopbouw',
|
||||
},
|
||||
customized_abutment: {
|
||||
en: 'Custom Abutment (Titanium)',
|
||||
fa: 'اباتمنت سفارشی (تیتانیوم)',
|
||||
nl: 'Aangepast abutment (titanium)',
|
||||
},
|
||||
prefabricated_abutment: {
|
||||
en: 'Prefabricated Abutment',
|
||||
fa: 'اباتمنت آماده',
|
||||
nl: 'Prefab Abutment',
|
||||
},
|
||||
ti_base_abutment: {
|
||||
en: 'Ti Base Abutment',
|
||||
fa: 'اباتمنت پایه تیتانیوم',
|
||||
nl: 'Ti Basis Abutment',
|
||||
},
|
||||
multi_unit_abutment: {
|
||||
en: 'Multi Unit Abutment',
|
||||
fa: 'اباتمنت مولتی یونیت',
|
||||
nl: 'Multi Unit Abutment',
|
||||
},
|
||||
zirconia_abutment: {
|
||||
en: 'Custom Abutment (Zirconia)',
|
||||
fa: 'اباتمنت سفارشی (زیرکونیا)',
|
||||
nl: 'Aangepast abutment (zirconia)',
|
||||
},
|
||||
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
|
||||
complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' },
|
||||
partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' },
|
||||
complete_denture: {
|
||||
en: 'Complete Denture',
|
||||
fa: 'دنچر کامل',
|
||||
nl: 'Volledige prothese',
|
||||
},
|
||||
partial_denture: {
|
||||
en: 'Partial Denture',
|
||||
fa: 'دنچر پارسیل',
|
||||
nl: 'Partiële prothese',
|
||||
},
|
||||
overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' },
|
||||
night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' },
|
||||
night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' },
|
||||
night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' },
|
||||
night_guard_soft: {
|
||||
en: 'Night Guard · Soft',
|
||||
fa: 'نایت گارد · نرم',
|
||||
nl: 'Nachtbeugel · Zacht',
|
||||
},
|
||||
night_guard_hard: {
|
||||
en: 'Night Guard · Hard',
|
||||
fa: 'نایت گارد · سخت',
|
||||
nl: 'Nachtbeugel · Hard',
|
||||
},
|
||||
night_guard_dual: {
|
||||
en: 'Night Guard · Dual laminate',
|
||||
fa: 'نایت گارد · دو لایه',
|
||||
nl: 'Nachtbeugel · Dual laminate',
|
||||
},
|
||||
bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' },
|
||||
clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' },
|
||||
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
|
||||
surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' },
|
||||
clear_aligner: {
|
||||
en: 'Clear Aligner',
|
||||
fa: 'الاینر شفاف',
|
||||
nl: 'Clear aligner',
|
||||
},
|
||||
soft_structure: {
|
||||
en: 'Soft Structure',
|
||||
fa: 'ساختار نرم',
|
||||
nl: 'Zachte Structuur',
|
||||
},
|
||||
surgical_guide: {
|
||||
en: 'Surgical Guide',
|
||||
fa: 'گاید جراحی',
|
||||
nl: 'Chirurgische mal',
|
||||
},
|
||||
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
|
||||
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
|
||||
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
|
||||
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
|
||||
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
|
||||
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
|
||||
veneer_zirconia: {
|
||||
en: 'Veneer Zirconia',
|
||||
fa: 'ونیر زیرکونیا',
|
||||
nl: 'Veneer Zirconia',
|
||||
},
|
||||
veneer_ips_press: {
|
||||
en: 'Veneer IPS Press',
|
||||
fa: 'ونیر IPS پرس',
|
||||
nl: 'Veneer IPS Press',
|
||||
},
|
||||
veneer_ips_cad: {
|
||||
en: 'Veneer IPS CAD',
|
||||
fa: 'ونیر IPS CAD',
|
||||
nl: 'Veneer IPS CAD',
|
||||
},
|
||||
zirconia_overlay: {
|
||||
en: 'Zirconia Overlay',
|
||||
fa: 'اورلی زیرکونیا',
|
||||
nl: 'Zirconia Overlay',
|
||||
},
|
||||
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
|
||||
};
|
||||
|
||||
export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
|
||||
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
|
||||
intraoral_scan: {
|
||||
en: 'Intraoral Scan',
|
||||
fa: 'اسکن داخل دهان',
|
||||
nl: 'Intraorale Scan',
|
||||
},
|
||||
choosing_abutment: {
|
||||
en: 'Choosing Abutment',
|
||||
fa: 'انتخاب اباتمنت',
|
||||
@@ -530,7 +722,11 @@ export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
|
||||
build_up: { en: 'Build Up', fa: 'بیلدآپ', nl: 'Opbouw' },
|
||||
stain: { en: 'Stain', fa: 'رنگآمیزی', nl: 'Kleuren' },
|
||||
glaze: { en: 'Glaze', fa: 'گلیز', nl: 'Glazuur' },
|
||||
polish_prep: { en: 'Polish/Prep', fa: 'پولیش/آمادهسازی', nl: 'Polijsten/Voorbereiding' },
|
||||
polish_prep: {
|
||||
en: 'Polish/Prep',
|
||||
fa: 'پولیش/آمادهسازی',
|
||||
nl: 'Polijsten/Voorbereiding',
|
||||
},
|
||||
packing: { en: 'Packing', fa: 'بستهبندی', nl: 'Verpakken' },
|
||||
shipping: { en: 'Shipping', fa: 'ارسال', nl: 'Verzending' },
|
||||
pressing: { en: 'Pressing', fa: 'پرس', nl: 'Persen' },
|
||||
@@ -549,8 +745,58 @@ function labelsToTranslations(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 7 prosthesis categories, worded from `frontend/messages/*.json`'s `category_*` keys
|
||||
* (decision 47) so the extraction prompt and the manual picker read identically on day one.
|
||||
* The frontend keeps its own message keys — migrating it to read these is out of scope
|
||||
* (decision 48) — this seed is written *from* those keys precisely so the two agree at the
|
||||
* point they diverge.
|
||||
*/
|
||||
export const PROSTHESIS_CATEGORY_LABELS: Record<
|
||||
string,
|
||||
Record<string, string>
|
||||
> = {
|
||||
crown: { en: 'Crowns', fa: 'روکشها', nl: 'Kronen' },
|
||||
indirect: {
|
||||
en: 'Veneer/Inlay/Onlay/Overlay',
|
||||
fa: 'ونیر/اینلی/آنلی/اورلی',
|
||||
nl: 'Veneer/Inlay/Onlay/Overlay',
|
||||
},
|
||||
implant: { en: 'Implants', fa: 'ایمپلنت', nl: 'Implantaten' },
|
||||
post_core: { en: 'Post & core', fa: 'پست و کور', nl: 'Stiftopbouw' },
|
||||
removable: { en: 'Removable', fa: 'متحرک', nl: 'Uitneembaar' },
|
||||
appliance: { en: 'Appliances', fa: 'اپلاینسها', nl: 'Apparatuur' },
|
||||
digital: { en: 'Digital', fa: 'دیجیتال', nl: 'Digitaal' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Only the 5 real subcategories — the `sub_*` message key set is wider (it also carries
|
||||
* technique names and two leaf codes); those are not subcategory catalog entities.
|
||||
*/
|
||||
export const PROSTHESIS_SUBCATEGORY_LABELS: Record<
|
||||
string,
|
||||
Record<string, string>
|
||||
> = {
|
||||
veneer: { en: 'Veneer', fa: 'ونیر', nl: 'Veneer' },
|
||||
inlay: { en: 'Inlay', fa: 'اینلی', nl: 'Inlay' },
|
||||
onlay: { en: 'Onlay', fa: 'آنلی', nl: 'Onlay' },
|
||||
overlay: { en: 'Overlay', fa: 'اورلی', nl: 'Overlay' },
|
||||
night_guard: { en: 'Night guard', fa: 'نایت گارد', nl: 'Nachtbeugel' },
|
||||
};
|
||||
|
||||
export const CATALOG_TRANSLATIONS: CatalogTranslationSeed[] = [
|
||||
...labelsToTranslations(CatalogEntityKind.TREATMENT_TYPE, TREATMENT_LABELS),
|
||||
...labelsToTranslations(CatalogEntityKind.PROSTHESIS_TYPE, PROSTHESIS_LABELS),
|
||||
...labelsToTranslations(CatalogEntityKind.LAB_WORKFLOW_STEP, WORKFLOW_STEP_LABELS),
|
||||
...labelsToTranslations(
|
||||
CatalogEntityKind.LAB_WORKFLOW_STEP,
|
||||
WORKFLOW_STEP_LABELS,
|
||||
),
|
||||
...labelsToTranslations(
|
||||
CatalogEntityKind.PROSTHESIS_CATEGORY,
|
||||
PROSTHESIS_CATEGORY_LABELS,
|
||||
),
|
||||
...labelsToTranslations(
|
||||
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
|
||||
PROSTHESIS_SUBCATEGORY_LABELS,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
@@ -332,6 +332,8 @@ enum CatalogEntityKind {
|
||||
TREATMENT_TYPE
|
||||
PROSTHESIS_TYPE
|
||||
LAB_WORKFLOW_STEP
|
||||
PROSTHESIS_CATEGORY
|
||||
PROSTHESIS_SUBCATEGORY
|
||||
}
|
||||
|
||||
model CatalogTranslation {
|
||||
|
||||
@@ -62,6 +62,14 @@ export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
|
||||
...FDI_LOWER_ARCH_ORDER,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Not FDI codes — jaw-level prosthesis targets on `LabCaseToothProsthesis.tooth`. Mirrors
|
||||
* `frontend/src/components/treatment/prosthesisTree.ts`'s `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER`
|
||||
* so the voice contract speaks the same sentinel the manual chart already writes.
|
||||
*/
|
||||
export const ARCH_TOOTH_UPPER = 'UA';
|
||||
export const ARCH_TOOTH_LOWER = 'LA';
|
||||
|
||||
export function isFdiTooth(value: unknown): value is string {
|
||||
return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,9 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
|
||||
async list(
|
||||
localeInput?: string | null,
|
||||
): Promise<ProsthesisTypeCatalogEntry[]> {
|
||||
await this.refresh();
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const codes = [...this.byCode.keys()];
|
||||
@@ -92,17 +94,64 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
||||
stackGroup: row.stackGroup,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||
.sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
|
||||
);
|
||||
}
|
||||
|
||||
/** The 7 distinct category codes present in the active catalog, with labels in `locale`. */
|
||||
async listCategories(
|
||||
localeInput?: string | null,
|
||||
): Promise<{ code: string; label: string }[]> {
|
||||
return this.listDistinct(
|
||||
(row) => row.category,
|
||||
CatalogEntityKind.PROSTHESIS_CATEGORY,
|
||||
localeInput,
|
||||
);
|
||||
}
|
||||
|
||||
/** The 5 distinct subcategory codes (veneer, inlay, onlay, overlay, night_guard, …). */
|
||||
async listSubcategories(
|
||||
localeInput?: string | null,
|
||||
): Promise<{ code: string; label: string }[]> {
|
||||
return this.listDistinct(
|
||||
(row) => row.subcategory,
|
||||
CatalogEntityKind.PROSTHESIS_SUBCATEGORY,
|
||||
localeInput,
|
||||
);
|
||||
}
|
||||
|
||||
private async listDistinct(
|
||||
pick: (row: CatalogRow) => string,
|
||||
entityKind: CatalogEntityKind,
|
||||
localeInput?: string | null,
|
||||
): Promise<{ code: string; label: string }[]> {
|
||||
await this.refresh();
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const codes = [
|
||||
...new Set([...this.byCode.values()].map(pick).filter(Boolean)),
|
||||
].sort();
|
||||
const labels = await this.catalogLabels.resolveLabels(
|
||||
entityKind,
|
||||
codes,
|
||||
locale,
|
||||
);
|
||||
return codes.map((code) => ({ code, label: labels.get(code) ?? code }));
|
||||
}
|
||||
|
||||
assertKnownProsthesisType(code: string): void {
|
||||
this.ensureLoaded();
|
||||
if (!this.byCode.has(code)) {
|
||||
throw new AppException(ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, HttpStatus.BAD_REQUEST);
|
||||
throw new AppException(
|
||||
ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getStepCodesForProsthesisType(prosthesisTypeCode: string): Promise<string[]> {
|
||||
async getStepCodesForProsthesisType(
|
||||
prosthesisTypeCode: string,
|
||||
): Promise<string[]> {
|
||||
const type = await this.prisma.prosthesisType.findUnique({
|
||||
where: { code: prosthesisTypeCode },
|
||||
select: {
|
||||
@@ -120,7 +169,10 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
||||
return type.steps.map((s) => s.labWorkflowStep.code);
|
||||
}
|
||||
|
||||
async resolveStepLabels(stepCodes: string[], locale: CatalogLocale): Promise<Map<string, string>> {
|
||||
async resolveStepLabels(
|
||||
stepCodes: string[],
|
||||
locale: CatalogLocale,
|
||||
): Promise<Map<string, string>> {
|
||||
return this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.LAB_WORKFLOW_STEP,
|
||||
stepCodes,
|
||||
|
||||
@@ -8,14 +8,24 @@ const LOCALE_NOTES: Record<string, string> = {
|
||||
'("دندون شماره ۲۶"). Digits may arrive in Persian or Latin script — either way, copy',
|
||||
'the number into "fdi" as two Latin digits. The descriptive form is quadrant-relative:',
|
||||
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
|
||||
'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just',
|
||||
'"بالا"/"پایین" in context, or "هر دو فک" (both jaws).',
|
||||
'A note is asked for with "بنویس", "یادداشت کن", "این را یادداشت کن", "بنویس که",',
|
||||
'"در توضیحات بنویس". Anything else the clinician says is not a note.',
|
||||
].join(' '),
|
||||
nl: [
|
||||
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
|
||||
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.',
|
||||
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six. A jaw is',
|
||||
'"bovenkaak" (upper) or "onderkaak" (lower), or "beide kaken" (both).',
|
||||
'A note is asked for with "schrijf op", "noteer", "zet in de notities". Anything else',
|
||||
'the clinician says is not a note.',
|
||||
].join(' '),
|
||||
en: [
|
||||
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
|
||||
'all tooth 26. The descriptive form is "upper right six".',
|
||||
'all tooth 26. The descriptive form is "upper right six". A jaw is "upper jaw"/"lower',
|
||||
'jaw", or "both jaws".',
|
||||
'A note is asked for with "write this in the notes", "note that", "add a note",',
|
||||
'"put in the comments". Anything else the clinician says is not a note.',
|
||||
].join(' '),
|
||||
};
|
||||
|
||||
@@ -24,6 +34,63 @@ function codeList(entries: { code: string; label: string }[]): string {
|
||||
return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n');
|
||||
}
|
||||
|
||||
/** "per-tooth" | "jaw" | "per-tooth or jaw" — derived from the leaves' own chartRegion, never hardcoded. */
|
||||
function regionNote(regions: ReadonlySet<string>): string {
|
||||
const isJaw = regions.has('arch');
|
||||
const isTooth = regions.has('crown') || regions.has('root');
|
||||
if (isJaw && isTooth)
|
||||
return 'per-tooth or jaw, depending on the specific code';
|
||||
return isJaw ? 'jaw' : 'per-tooth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the prosthesis catalog as the tree it is: category -> (subcategory ->) leaves.
|
||||
* Nothing here is hardcoded — every code, label and region annotation comes from the data
|
||||
* `buildCatalog` fetched, so a catalog change needs no prompt change.
|
||||
*/
|
||||
function prosthesisTree(catalog: ExtractionCatalog): string {
|
||||
const byCategory = new Map<string, ExtractionCatalog['prosthesisTypes']>();
|
||||
for (const leaf of catalog.prosthesisTypes) {
|
||||
const list = byCategory.get(leaf.category) ?? [];
|
||||
list.push(leaf);
|
||||
byCategory.set(leaf.category, list);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const category of catalog.prosthesisCategories) {
|
||||
const leaves = byCategory.get(category.code) ?? [];
|
||||
const regions = new Set(leaves.map((l) => l.chartRegion));
|
||||
lines.push(
|
||||
`CATEGORY ${category.code} = ${category.label} (${regionNote(regions)})`,
|
||||
);
|
||||
|
||||
const bySubcategory = new Map<string, typeof leaves>();
|
||||
const bare: typeof leaves = [];
|
||||
for (const leaf of leaves) {
|
||||
if (leaf.subcategory) {
|
||||
const list = bySubcategory.get(leaf.subcategory) ?? [];
|
||||
list.push(leaf);
|
||||
bySubcategory.set(leaf.subcategory, list);
|
||||
} else {
|
||||
bare.push(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
for (const leaf of bare) {
|
||||
lines.push(` - ${leaf.code} = ${leaf.label}`);
|
||||
}
|
||||
for (const sub of catalog.prosthesisSubcategories) {
|
||||
const subLeaves = bySubcategory.get(sub.code);
|
||||
if (!subLeaves || subLeaves.length === 0) continue;
|
||||
lines.push(` SUBCATEGORY ${sub.code} = ${sub.label}`);
|
||||
for (const leaf of subLeaves) {
|
||||
lines.push(` - ${leaf.code} = ${leaf.label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildExtractionPrompt(
|
||||
transcript: string,
|
||||
catalog: ExtractionCatalog,
|
||||
@@ -36,17 +103,22 @@ export function buildExtractionPrompt(
|
||||
'You are a parser, not an assistant: report only what was said.',
|
||||
'',
|
||||
'HARD RULES',
|
||||
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
|
||||
' must be codes from the lists below. labId must be an id from the lab list. If what you',
|
||||
' heard is not in a list, use null.',
|
||||
'1. Never invent a code. treatmentType and prosthesis[].types[] must be codes from the',
|
||||
' lists below. labId must be an id from the lab list. If what you heard is not in a',
|
||||
' list, use null (or omit it from prosthesis[].types[]).',
|
||||
'2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
|
||||
" flip to the viewer's point of view.",
|
||||
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
|
||||
' If no deadline was mentioned, use due.kind = "none".',
|
||||
'4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
|
||||
' what was heard.',
|
||||
'4. Copy the exact spoken words for each tooth, jaw and prosthesis instruction into',
|
||||
' "spoken", so the clinician can see what was heard.',
|
||||
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
|
||||
' one is not.',
|
||||
'6. A whole jaw is not a tooth. Never put a jaw in the "teeth" list. A jaw appears only',
|
||||
' as a prosthesis[].targets entry with "arch" set and "position" null.',
|
||||
'7. Never decide something is a note. Fill "comment" only when the clinician asked for',
|
||||
' one, and report the words they used in "commentTrigger". Speech you cannot place in',
|
||||
' a field is simply not reported — it is never a note.',
|
||||
'',
|
||||
'TOOTH NUMBERS',
|
||||
'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as',
|
||||
@@ -71,8 +143,24 @@ export function buildExtractionPrompt(
|
||||
'TREATMENT TYPE CODES',
|
||||
codeList(catalog.treatmentTypes),
|
||||
'',
|
||||
'PROSTHESIS TYPE CODES',
|
||||
codeList(catalog.prosthesisTypes),
|
||||
'PROSTHESIS WORK — prosthesis[]',
|
||||
'One entry per spoken instruction: "these targets get these jobs". Each entry is',
|
||||
'{ targets, types, spoken }:',
|
||||
'- targets: the teeth OR jaw(s) this instruction is for. A jaw target is a tooth object',
|
||||
' with "arch" set ("upper", "lower", or "both" for both jaws) and "position" left null —',
|
||||
' never invent a tooth number for a jaw-level appliance. A tooth target is the normal',
|
||||
' fdi / arch+side+position shape above.',
|
||||
'- types: one or more codes from the tree below, applied to every target in this entry.',
|
||||
' More than one code means a STACK on the same tooth — e.g. an abutment plus a crown on',
|
||||
' one implant site: types: ["zirconia_abutment", "monolithic_zirconia"].',
|
||||
' If only the general term was said ("روکش", "veneer") and not a specific material, use',
|
||||
' the CATEGORY or SUBCATEGORY code instead of guessing a leaf.',
|
||||
'- A tooth named with no job at all still belongs in the top-level "teeth" list, not here.',
|
||||
'- If several teeth share one job, list them all as targets in one entry rather than',
|
||||
' repeating the entry — "12 and 13, PFM crown on both" -> one entry, two targets.',
|
||||
'',
|
||||
'PROSTHESIS TYPE CODES (tree — CATEGORY and SUBCATEGORY are marked; the rest are leaves)',
|
||||
prosthesisTree(catalog),
|
||||
'',
|
||||
'LABS THIS CLINIC CAN SEND TO',
|
||||
catalog.labs.length > 0
|
||||
@@ -81,8 +169,13 @@ export function buildExtractionPrompt(
|
||||
'',
|
||||
'OTHER FIELDS',
|
||||
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
|
||||
'- comment: clinical notes, in the language spoken. Omit the parts already captured as',
|
||||
' treatment type, teeth or deadline.',
|
||||
'- comment + commentTrigger: a note is written ONLY when the clinician asked for one.',
|
||||
' "commentTrigger" is the exact words that asked, copied from the transcript. "comment"',
|
||||
' is what they then dictated, WITHOUT those words: for "بنویس که بیمار حساسیت به سرما',
|
||||
' دارد", commentTrigger is "بنویس که" and comment is "بیمار حساسیت به سرما دارد".',
|
||||
' If nobody asked, BOTH are null. Never sweep up leftover speech, filler, small talk or',
|
||||
' a diagnosis nobody asked you to record. A comment without a trigger is discarded, so',
|
||||
' guessing costs the clinician the note.',
|
||||
'- labMatchExact: true only when the spoken name matched a lab name exactly.',
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi';
|
||||
import {
|
||||
PROSTHESIS_TYPES,
|
||||
PROSTHESIS_CATEGORY_LABELS,
|
||||
PROSTHESIS_SUBCATEGORY_LABELS,
|
||||
} from '../../../prisma/catalog-seed-data';
|
||||
import {
|
||||
resolveConnectedSpans,
|
||||
resolveProsthesis,
|
||||
resolveProsthesisAssignment,
|
||||
resolveVoiceIntent,
|
||||
type ProsthesisLeaf,
|
||||
type ResolveContext,
|
||||
} from './extraction.resolver';
|
||||
import type { ToothIntent, VoiceIntent } from './voice.types';
|
||||
import type {
|
||||
ProsthesisAssignment,
|
||||
ToothIntent,
|
||||
UnresolvedItem,
|
||||
VoiceIntent,
|
||||
} from './voice.types';
|
||||
|
||||
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
||||
kind: 'explicit',
|
||||
@@ -12,12 +24,75 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
||||
spoken,
|
||||
});
|
||||
|
||||
const positional = (
|
||||
overrides: Partial<Extract<ToothIntent, { kind: 'positional' }>> = {},
|
||||
): ToothIntent => ({
|
||||
kind: 'positional',
|
||||
arch: undefined as never,
|
||||
side: undefined as never,
|
||||
position: Number.NaN,
|
||||
spoken: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const PROSTHESIS_LEAVES: ProsthesisLeaf[] = [
|
||||
{
|
||||
code: 'pfm_crown',
|
||||
category: 'crown',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
},
|
||||
{
|
||||
code: 'monolithic_zirconia',
|
||||
category: 'crown',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
},
|
||||
{
|
||||
code: 'zirconia_abutment',
|
||||
category: 'implant',
|
||||
subcategory: '',
|
||||
chartRegion: 'root',
|
||||
},
|
||||
{
|
||||
code: 'screw_retained',
|
||||
category: 'implant',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
},
|
||||
{
|
||||
code: 'night_guard_soft',
|
||||
category: 'appliance',
|
||||
subcategory: 'night_guard',
|
||||
chartRegion: 'arch',
|
||||
},
|
||||
{
|
||||
code: 'complete_denture',
|
||||
category: 'removable',
|
||||
subcategory: '',
|
||||
chartRegion: 'arch',
|
||||
},
|
||||
{
|
||||
code: 'partial_denture',
|
||||
category: 'removable',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
},
|
||||
];
|
||||
|
||||
const CTX: ResolveContext = {
|
||||
todayIso: '2025-10-11',
|
||||
weekStartJs: 6, // Saturday — the fa week
|
||||
|
||||
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
|
||||
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
|
||||
prosthesisLeaves: PROSTHESIS_LEAVES,
|
||||
prosthesisCategoryCodes: new Set([
|
||||
'crown',
|
||||
'implant',
|
||||
'removable',
|
||||
'appliance',
|
||||
]),
|
||||
prosthesisSubcategoryCodes: new Set(['night_guard']),
|
||||
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
|
||||
};
|
||||
|
||||
@@ -111,122 +186,317 @@ describe('resolveConnectedSpans', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProsthesis', () => {
|
||||
const allowed = CTX.prosthesisTypeCodes;
|
||||
|
||||
it('expands the default across every tooth', () => {
|
||||
const result = resolveProsthesis(
|
||||
{ defaultType: 'monolithic_zirconia', overrides: [] },
|
||||
['14', '15'],
|
||||
allowed,
|
||||
describe('resolveProsthesisAssignment', () => {
|
||||
function resolve(assignment: ProsthesisAssignment, index = 0) {
|
||||
const unresolved: UnresolvedItem[] = [];
|
||||
const resolved = resolveProsthesisAssignment(
|
||||
assignment,
|
||||
index,
|
||||
CTX,
|
||||
unresolved,
|
||||
);
|
||||
expect(result.prosthesis?.byTooth).toEqual({
|
||||
'14': 'monolithic_zirconia',
|
||||
'15': 'monolithic_zirconia',
|
||||
return { resolved, unresolved };
|
||||
}
|
||||
|
||||
it('resolves explicit tooth targets to FDI codes', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [tooth('12'), tooth('13')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'روکش پیافام برای ۱۲ و ۱۳',
|
||||
});
|
||||
expect(result.prosthesis?.complete).toBe(true);
|
||||
expect(resolved.targets.sort()).toEqual(['12', '13']);
|
||||
expect(resolved.types).toEqual(['pfm_crown']);
|
||||
});
|
||||
|
||||
it('applies per-tooth overrides on top of the default', () => {
|
||||
const result = resolveProsthesis(
|
||||
{
|
||||
defaultType: 'monolithic_zirconia',
|
||||
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
|
||||
},
|
||||
['14', '26'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.prosthesis?.byTooth).toEqual({
|
||||
'14': 'monolithic_zirconia',
|
||||
'26': 'pfm_crown',
|
||||
it('resolves a stack — more than one type on the same target', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['zirconia_abutment', 'monolithic_zirconia'],
|
||||
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
|
||||
});
|
||||
expect(result.prosthesis?.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the map incomplete when a tooth ends up untyped', () => {
|
||||
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
|
||||
const result = resolveProsthesis(
|
||||
{
|
||||
defaultType: null,
|
||||
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
|
||||
},
|
||||
['14', '15'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.prosthesis?.complete).toBe(false);
|
||||
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
|
||||
});
|
||||
|
||||
it('rejects a catalog code the clinic does not have', () => {
|
||||
const result = resolveProsthesis(
|
||||
{ defaultType: 'gold_foil', overrides: [] },
|
||||
['14'],
|
||||
allowed,
|
||||
);
|
||||
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
|
||||
expect(result.prosthesis).toBeNull();
|
||||
expect(result.unresolved).toEqual([
|
||||
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
|
||||
expect(resolved.targets).toEqual(['12']);
|
||||
expect(resolved.types.sort()).toEqual([
|
||||
'monolithic_zirconia',
|
||||
'zirconia_abutment',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an override for a tooth that is not selected', () => {
|
||||
const result = resolveProsthesis(
|
||||
{
|
||||
defaultType: 'monolithic_zirconia',
|
||||
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
|
||||
},
|
||||
['14'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
|
||||
expect(result.unresolved).toEqual([
|
||||
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
|
||||
]);
|
||||
it('resolves an arch target with no position to the jaw sentinel', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [positional({ arch: 'upper' })],
|
||||
types: ['night_guard_soft'],
|
||||
spoken: 'نایت گارد فک بالا',
|
||||
});
|
||||
expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
|
||||
});
|
||||
|
||||
it('reports no prosthesis at all when the object carries nothing usable', () => {
|
||||
// An empty-but-present map would paint a plain restoration with a fabricated
|
||||
// "incomplete, cannot ship" warning.
|
||||
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
|
||||
expect(
|
||||
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
|
||||
).toBeNull();
|
||||
it('resolves "both jaws" to both sentinels', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [positional({ arch: 'both' })],
|
||||
types: ['night_guard_soft'],
|
||||
spoken: 'نایت گارد هر دو فک',
|
||||
});
|
||||
expect(resolved.targets.sort()).toEqual(
|
||||
[ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies a leaf, a category and a subcategory code independently', () => {
|
||||
const leaf = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: '',
|
||||
});
|
||||
expect(leaf.resolved.types).toEqual(['pfm_crown']);
|
||||
expect(leaf.unresolved).toEqual([]);
|
||||
|
||||
const category = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['crown'],
|
||||
spoken: 'روکش',
|
||||
});
|
||||
expect(category.resolved.types).toEqual([]);
|
||||
expect(category.unresolved).toEqual([
|
||||
{
|
||||
spoken: 'روکش',
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: ['monolithic_zirconia', 'pfm_crown'],
|
||||
assignmentIndex: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const subcategory = resolve({
|
||||
targets: [positional({ arch: 'upper' })],
|
||||
types: ['night_guard'],
|
||||
spoken: 'نایت گارد',
|
||||
});
|
||||
expect(subcategory.resolved.types).toEqual([]);
|
||||
expect(subcategory.unresolved[0]).toMatchObject({
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: ['night_guard_soft'],
|
||||
});
|
||||
});
|
||||
|
||||
it('asserts the leaf, category and subcategory namespaces are disjoint on the live catalog', () => {
|
||||
const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code));
|
||||
const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category));
|
||||
const subcategoryCodes = new Set(
|
||||
PROSTHESIS_TYPES.map((t) => t.subcategory).filter(
|
||||
(code): code is string => Boolean(code),
|
||||
),
|
||||
);
|
||||
for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false);
|
||||
for (const code of subcategoryCodes) {
|
||||
expect(leafCodes.has(code)).toBe(false);
|
||||
expect(categoryCodes.has(code)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports no prosthesis when there are no teeth to type', () => {
|
||||
const result = resolveProsthesis(
|
||||
{ defaultType: 'monolithic_zirconia', overrides: [] },
|
||||
[],
|
||||
allowed,
|
||||
);
|
||||
expect(result.prosthesis).toBeNull();
|
||||
});
|
||||
|
||||
it('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(
|
||||
it('reports a code not in the supplied catalog', () => {
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['gold_foil'],
|
||||
spoken: '',
|
||||
});
|
||||
expect(resolved.types).toEqual([]);
|
||||
expect(unresolved).toEqual([
|
||||
{
|
||||
defaultType: 'monolithic_zirconia',
|
||||
overrides: [
|
||||
{
|
||||
tooth: { kind: 'explicit', fdi: '99', spoken: 'نود و نه' },
|
||||
type: 'pfm_crown',
|
||||
},
|
||||
],
|
||||
spoken: 'gold_foil',
|
||||
reason: 'unknown_catalog_code',
|
||||
assignmentIndex: 0,
|
||||
},
|
||||
['14'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.unresolved).toEqual([
|
||||
{ spoken: 'نود و نه', reason: 'malformed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when no prosthesis was spoken', () => {
|
||||
expect(resolveProsthesis(null, ['14'], allowed).prosthesis).toBeNull();
|
||||
it('rejects an arch code aimed at a tooth', () => {
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['night_guard_soft'],
|
||||
spoken: 'دندون ۱۲ نایت گارد',
|
||||
});
|
||||
expect(resolved.targets).toEqual([]);
|
||||
expect(unresolved).toEqual([
|
||||
{ spoken: '12', reason: 'code_not_valid_for_target', assignmentIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a tooth code aimed at a jaw', () => {
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'فک بالا',
|
||||
});
|
||||
expect(resolved.targets).toEqual([]);
|
||||
expect(unresolved).toEqual([
|
||||
{
|
||||
spoken: 'فک بالا',
|
||||
reason: 'code_not_valid_for_target',
|
||||
assignmentIndex: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows a mixed-region category to the leaves the target can carry', () => {
|
||||
// complete_denture is 'arch', partial_denture is 'crown'. Deferring the check until a
|
||||
// leaf was picked left nothing to complete it, and wrote a complete denture onto one
|
||||
// tooth. The target kind narrows the candidates instead, so an impossible leaf is never
|
||||
// offered.
|
||||
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',
|
||||
candidates: ['partial_denture'],
|
||||
}),
|
||||
);
|
||||
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).toContainEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: ['complete_denture'],
|
||||
}),
|
||||
);
|
||||
expect(jawTarget.unresolved).not.toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a category with no leaf the target can carry', () => {
|
||||
// `implant` spans root + crown, both tooth regions, so it is not a mixed tooth/arch
|
||||
// category and must not defer. Aimed at a jaw it is a contradiction.
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
|
||||
types: ['implant'],
|
||||
spoken: 'ایمپلنت بالا',
|
||||
});
|
||||
expect(resolved.targets).toEqual([]);
|
||||
expect(resolved.types).toEqual([]);
|
||||
expect(unresolved).toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
expect(unresolved).not.toContainEqual(
|
||||
expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a stack where only one code suits the target', () => {
|
||||
// One legal crown must not admit an arch-only appliance onto the same tooth. Validating
|
||||
// with `some` over the stack's regions did exactly that.
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['pfm_crown', 'night_guard_soft'],
|
||||
spoken: 'دندون ۱۲ روکش و نایت گارد',
|
||||
});
|
||||
expect(resolved.targets).toEqual([]);
|
||||
expect(unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
spoken: '12',
|
||||
reason: 'code_not_valid_for_target',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a stack whose every code suits the target', () => {
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['zirconia_abutment', 'monolithic_zirconia'],
|
||||
spoken: 'دندون ۱۲ ایمپلنت با روکش زیرکونیا',
|
||||
});
|
||||
expect(resolved.targets).toEqual(['12']);
|
||||
expect(resolved.types).toEqual([
|
||||
'zirconia_abutment',
|
||||
'monolithic_zirconia',
|
||||
]);
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it('a target with no types resolves with an empty types[], and does not fail the assignment', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [tooth('13'), tooth('14')],
|
||||
types: [],
|
||||
spoken: '۱۳ و ۱۴',
|
||||
});
|
||||
// Nothing to validate a region against yet, so a bare target still resolves as valid
|
||||
// geometry — the frontend renders it struck through ("no prosthesis heard") because
|
||||
// `types` is empty and no `prosthesis_type_ambiguous` item references this assignment.
|
||||
expect(resolved.targets.sort()).toEqual(['13', '14']);
|
||||
expect(resolved.types).toEqual([]);
|
||||
});
|
||||
|
||||
it('one target failing validity does not exclude the rest of the assignment', () => {
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [tooth('12'), positional({ arch: 'upper' })],
|
||||
types: ['pfm_crown'],
|
||||
spoken: '',
|
||||
});
|
||||
expect(resolved.targets).toEqual(['12']);
|
||||
expect(unresolved).toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('carries an assignment target that could not be resolved, with the arch candidates', () => {
|
||||
const { unresolved } = resolve({
|
||||
targets: [positional({ spoken: 'نایت گارد' })],
|
||||
types: ['night_guard_soft'],
|
||||
spoken: 'نایت گارد',
|
||||
});
|
||||
expect(unresolved).toContainEqual({
|
||||
spoken: 'نایت گارد',
|
||||
reason: 'arch_not_spoken',
|
||||
candidates: ['upper', 'lower'],
|
||||
assignmentIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the assignment index on a missing-quadrant target', () => {
|
||||
const { unresolved } = resolve({
|
||||
targets: [positional({ position: 2, spoken: 'دندون دو' })],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'دندون دو روکش',
|
||||
});
|
||||
expect(unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'tooth_missing_quadrant',
|
||||
assignmentIndex: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every prosthesis category and subcategory has a non-empty label in fa/en/nl', () => {
|
||||
const locales = ['fa', 'en', 'nl'] as const;
|
||||
|
||||
it.each(Object.entries(PROSTHESIS_CATEGORY_LABELS))(
|
||||
'category %s',
|
||||
(_code, labels) => {
|
||||
for (const locale of locales) {
|
||||
expect(labels[locale]?.trim()).toBeTruthy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(Object.entries(PROSTHESIS_SUBCATEGORY_LABELS))(
|
||||
'subcategory %s',
|
||||
(_code, labels) => {
|
||||
for (const locale of locales) {
|
||||
expect(labels[locale]?.trim()).toBeTruthy();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('resolveVoiceIntent', () => {
|
||||
@@ -235,7 +505,8 @@ describe('resolveVoiceIntent', () => {
|
||||
teeth: [tooth('14'), tooth('15')],
|
||||
connectedSpans: [],
|
||||
comment: ' حساسیت به سرما ',
|
||||
prosthesis: null,
|
||||
commentTrigger: 'بنویس که',
|
||||
prosthesis: [],
|
||||
labId: null,
|
||||
labMatchExact: false,
|
||||
due: null,
|
||||
@@ -246,7 +517,7 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.treatmentType).toBe('restoration');
|
||||
expect(result.teeth).toEqual(['14', '15']);
|
||||
expect(result.comment).toBe('حساسیت به سرما');
|
||||
expect(result.prosthesis).toBeNull();
|
||||
expect(result.prosthesisAssignments).toEqual([]);
|
||||
expect(result.unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -299,27 +570,150 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.labMatchExact).toBe(false);
|
||||
});
|
||||
|
||||
it('applies prosthesis over the span-expanded tooth set', () => {
|
||||
it('forces treatmentType to prosthesis when an assignment resolves', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
treatmentType: 'prosthesis',
|
||||
teeth: [tooth('14')],
|
||||
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
|
||||
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
|
||||
treatmentType: 'restoration',
|
||||
prosthesis: [
|
||||
{
|
||||
targets: [tooth('12')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'روکش برای ۱۲',
|
||||
},
|
||||
],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
// 15 was never spoken but is part of the bridge, so it must carry a type too.
|
||||
expect(result.teeth).toEqual(['14', '15', '16']);
|
||||
expect(result.prosthesis?.complete).toBe(true);
|
||||
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
|
||||
'14',
|
||||
'15',
|
||||
'16',
|
||||
expect(result.treatmentType).toBe('prosthesis');
|
||||
expect(result.prosthesisAssignments).toEqual([
|
||||
{ targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not force prosthesis when every assignment resolved no target', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
prosthesis: [{ targets: [], types: ['pfm_crown'], spoken: '' }],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
expect(result.treatmentType).toBe('restoration');
|
||||
});
|
||||
|
||||
it('does not treat a spoken jaw as a broken tooth', () => {
|
||||
// "یه کامپلیت دنچر برای فک بالا" — the model names the jaw in `teeth` as well as in the
|
||||
// assignment target. The arch reached the form correctly, but the duplicate reported
|
||||
// `position_out_of_range`, so the sheet asked which tooth was meant. No tooth was said.
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
treatmentType: null,
|
||||
teeth: [positional({ arch: 'upper', spoken: 'فک بالا' })],
|
||||
prosthesis: [
|
||||
{
|
||||
targets: [positional({ arch: 'upper', spoken: 'فک بالا' })],
|
||||
types: ['complete_denture'],
|
||||
spoken: 'یه کامپلیت دنچر برای فک بالا',
|
||||
},
|
||||
],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
expect(result.teeth).toEqual([]);
|
||||
expect(result.unresolved).toEqual([]);
|
||||
expect(result.prosthesisAssignments[0].targets).toEqual([ARCH_TOOTH_UPPER]);
|
||||
expect(result.treatmentType).toBe('prosthesis');
|
||||
});
|
||||
|
||||
it('drops a both-jaws reference from the teeth list too', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
teeth: [positional({ arch: 'both', spoken: 'هر دو فک' })],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
expect(result.teeth).toEqual([]);
|
||||
expect(result.unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it('still reports a tooth whose position is out of range', () => {
|
||||
// The jaw filter must not swallow a real fault: a position was given, and it is wrong.
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
teeth: [
|
||||
positional({
|
||||
arch: 'upper',
|
||||
side: 'patient_right',
|
||||
position: 9,
|
||||
spoken: 'دندون نه',
|
||||
}),
|
||||
],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
expect(result.teeth).toEqual([]);
|
||||
expect(result.unresolved).toContainEqual({
|
||||
spoken: 'دندون نه',
|
||||
reason: 'position_out_of_range',
|
||||
});
|
||||
});
|
||||
|
||||
it('still offers candidates for a tooth described without its quadrant', () => {
|
||||
// arch + position, no side: a real tooth, under-specified. Must survive the filter.
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
teeth: [positional({ arch: 'upper', position: 2, spoken: 'دو بالا' })],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
expect(result.unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
spoken: 'دو بالا',
|
||||
reason: 'tooth_missing_quadrant',
|
||||
candidates: ['12', '22'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a note the clinician never asked for', () => {
|
||||
// The whole point: leftover speech the model decided was a note is discarded. Before this
|
||||
// rule the prompt told it to sweep up "the parts already captured" as a comment.
|
||||
const result = resolveVoiceIntent(
|
||||
{ ...base, comment: 'بیمار عصبی بود', commentTrigger: null },
|
||||
CTX,
|
||||
);
|
||||
expect(result.comment).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a note whose trigger is only whitespace', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{ ...base, comment: 'بیمار عصبی بود', commentTrigger: ' ' },
|
||||
CTX,
|
||||
);
|
||||
expect(result.comment).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a note that was asked for, trimmed', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{ ...base, comment: ' حساسیت به سرما ', commentTrigger: 'بنویس که' },
|
||||
CTX,
|
||||
);
|
||||
expect(result.comment).toBe('حساسیت به سرما');
|
||||
});
|
||||
|
||||
it('reports no note when the trigger was heard but nothing followed it', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{ ...base, comment: ' ', commentTrigger: 'بنویس که' },
|
||||
CTX,
|
||||
);
|
||||
expect(result.comment).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a due date through the same context', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
|
||||
@@ -328,7 +722,7 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.dueDate).toBe('2025-10-16');
|
||||
});
|
||||
|
||||
it('collects unresolved items from every stage', () => {
|
||||
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
@@ -336,16 +730,34 @@ describe('resolveVoiceIntent', () => {
|
||||
teeth: [tooth('51', 'شیری')],
|
||||
connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
|
||||
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
|
||||
prosthesis: [
|
||||
{
|
||||
targets: [tooth('99', 'نود و نه')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
const reasons = result.unresolved.map((u) => u.reason).sort();
|
||||
expect(reasons).toEqual([
|
||||
'invalid_date',
|
||||
'malformed',
|
||||
'not_permanent_tooth',
|
||||
'span_not_same_arch',
|
||||
'unknown_catalog_code',
|
||||
]);
|
||||
|
||||
const outsideAssignment = result.unresolved.filter(
|
||||
(u) => u.reason === 'not_permanent_tooth',
|
||||
);
|
||||
expect(outsideAssignment[0].assignmentIndex).toBeUndefined();
|
||||
|
||||
const insideAssignment = result.unresolved.filter(
|
||||
(u) => u.reason === 'malformed',
|
||||
);
|
||||
expect(insideAssignment[0].assignmentIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an empty comment as absent', () => {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import {
|
||||
ARCH_TOOTH_LOWER,
|
||||
ARCH_TOOTH_UPPER,
|
||||
isFdiTooth,
|
||||
normalizeFdiCode,
|
||||
sameArch,
|
||||
sortInArchOrder,
|
||||
teethBetweenInclusive,
|
||||
toFdi,
|
||||
type Arch,
|
||||
type PatientSide,
|
||||
} from '../../common/fdi';
|
||||
import { resolveDueDate } from './due-date.resolver';
|
||||
import {
|
||||
@@ -10,9 +17,10 @@ import {
|
||||
} from './tooth-intent.resolver';
|
||||
import type {
|
||||
ConnectedSpanIntent,
|
||||
ProsthesisIntent,
|
||||
ProsthesisAssignment,
|
||||
ToothIntent,
|
||||
UnresolvedItem,
|
||||
UnresolvedReason,
|
||||
VoiceIntent,
|
||||
} from './voice.types';
|
||||
|
||||
@@ -22,16 +30,16 @@ export type ResolvedToothGroup = {
|
||||
teeth: string[];
|
||||
};
|
||||
|
||||
export type ResolvedProsthesis = {
|
||||
/** FDI code → prosthesis type code. */
|
||||
byTooth: Record<string, string>;
|
||||
/**
|
||||
* True when every selected tooth carries a code. A prosthesis detail cannot be shipped
|
||||
* otherwise (`assertCompleteToothProsthesisMap`), so the review sheet surfaces the gap
|
||||
* here rather than letting it fail at dispatch.
|
||||
*/
|
||||
complete: boolean;
|
||||
missingTeeth: string[];
|
||||
/**
|
||||
* One spoken instruction, resolved: these targets (FDI codes, or `'UA'`/`'LA'` jaw sentinels —
|
||||
* mirrors the manual chart's own convention) get these leaf codes. Empty `targets` or `types`
|
||||
* means every target, or every type, this assignment named turned out unresolved; the
|
||||
* assignment still appears so its index keeps meaning for `UnresolvedItem.assignmentIndex`.
|
||||
*/
|
||||
export type ResolvedProsthesisAssignment = {
|
||||
targets: string[];
|
||||
types: string[];
|
||||
spoken: string;
|
||||
};
|
||||
|
||||
export type ResolvedExtraction = {
|
||||
@@ -39,23 +47,33 @@ export type ResolvedExtraction = {
|
||||
teeth: string[];
|
||||
toothSelectionGroups: ResolvedToothGroup[];
|
||||
comment: string | null;
|
||||
prosthesis: ResolvedProsthesis | null;
|
||||
prosthesisAssignments: ResolvedProsthesisAssignment[];
|
||||
labId: string | null;
|
||||
labMatchExact: boolean;
|
||||
dueDate: string | null;
|
||||
unresolved: UnresolvedItem[];
|
||||
};
|
||||
|
||||
/** A leaf prosthesis catalog entry, as `ProsthesisCatalogService.list()` already returns it. */
|
||||
export type ProsthesisLeaf = {
|
||||
code: string;
|
||||
category: string;
|
||||
subcategory: string;
|
||||
chartRegion: string;
|
||||
};
|
||||
|
||||
export type ResolveContext = {
|
||||
todayIso: string;
|
||||
/** JS weekday index the clinician's week starts on — see weekStartForLocale. */
|
||||
weekStartJs: number;
|
||||
treatmentTypeCodes: ReadonlySet<string>;
|
||||
prosthesisTypeCodes: ReadonlySet<string>;
|
||||
prosthesisLeaves: readonly ProsthesisLeaf[];
|
||||
prosthesisCategoryCodes: ReadonlySet<string>;
|
||||
prosthesisSubcategoryCodes: ReadonlySet<string>;
|
||||
linkedLabIds: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
function spokenOf(intent: ToothIntent): string {
|
||||
function spokenOf(intent: unknown): string {
|
||||
const spoken = (intent as { spoken?: unknown })?.spoken;
|
||||
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
|
||||
}
|
||||
@@ -165,76 +183,323 @@ 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
|
||||
* how clinicians actually speak.
|
||||
* A prosthesis assignment target is a tooth or a jaw — nothing on the wire declares which.
|
||||
* A position with no quadrant is the familiar "دو" ambiguity; no position at all, with an
|
||||
* arch, names a jaw; no position AND no arch means the jaw itself was never spoken.
|
||||
*/
|
||||
export function resolveProsthesis(
|
||||
intent: ProsthesisIntent | null | undefined,
|
||||
teeth: readonly string[],
|
||||
allowed: ReadonlySet<string>,
|
||||
): { prosthesis: ResolvedProsthesis | null; unresolved: UnresolvedItem[] } {
|
||||
if (!intent || typeof intent !== 'object')
|
||||
return { prosthesis: null, unresolved: [] };
|
||||
|
||||
const unresolved: UnresolvedItem[] = [];
|
||||
const defaultType = resolveCatalogCode(intent.defaultType, allowed);
|
||||
if (intent.defaultType != null && !defaultType) {
|
||||
unresolved.push({
|
||||
spoken: String(intent.defaultType),
|
||||
reason: 'unknown_catalog_code',
|
||||
});
|
||||
function resolveAssignmentTarget(target: ToothIntent): TargetResolution {
|
||||
if (!target || typeof target !== 'object') {
|
||||
return { kind: 'unresolved', reason: 'malformed' };
|
||||
}
|
||||
|
||||
const byTooth: Record<string, string> = {};
|
||||
const selected = new Set(teeth);
|
||||
if (defaultType) {
|
||||
for (const tooth of teeth) byTooth[tooth] = defaultType;
|
||||
if (target.kind === 'explicit') {
|
||||
const fdi = normalizeFdiCode((target as { fdi?: unknown }).fdi);
|
||||
if (isFdiTooth(fdi)) return { kind: 'tooth', fdi };
|
||||
// Quadrants 1-4 are permanent; a well-formed quadrant+position reaching here is 5-8:
|
||||
// deciduous. Anything else is noise.
|
||||
return {
|
||||
kind: 'unresolved',
|
||||
reason: /^[1-8][1-8]$/.test(fdi) ? 'not_permanent_tooth' : 'malformed',
|
||||
};
|
||||
}
|
||||
|
||||
const overrides: ProsthesisIntent['overrides'] = Array.isArray(
|
||||
intent.overrides,
|
||||
)
|
||||
? intent.overrides
|
||||
if (target.kind === 'positional') {
|
||||
const positionGiven =
|
||||
typeof target.position === 'number' && !Number.isNaN(target.position);
|
||||
const archGiven =
|
||||
target.arch === 'upper' ||
|
||||
target.arch === 'lower' ||
|
||||
target.arch === 'both';
|
||||
const sideGiven =
|
||||
target.side === 'patient_right' || target.side === 'patient_left';
|
||||
|
||||
if (positionGiven) {
|
||||
if (
|
||||
!Number.isInteger(target.position) ||
|
||||
target.position < 1 ||
|
||||
target.position > 8
|
||||
) {
|
||||
return { kind: 'unresolved', reason: 'position_out_of_range' };
|
||||
}
|
||||
if (archGiven && target.arch !== 'both' && sideGiven) {
|
||||
const fdi = toFdi(target.arch, target.side, target.position);
|
||||
if (fdi) return { kind: 'tooth', fdi };
|
||||
}
|
||||
// A position was said but the tooth's own quadrant was not (or "both" was said for
|
||||
// what must be a single tooth) — the familiar "دو" ambiguity.
|
||||
return {
|
||||
kind: 'unresolved',
|
||||
reason: 'tooth_missing_quadrant',
|
||||
candidates: quadrantCandidatesForTarget(target),
|
||||
};
|
||||
}
|
||||
|
||||
if (archGiven) return { kind: 'jaw', arch: target.arch };
|
||||
// Neither a tooth position nor a jaw was said.
|
||||
return {
|
||||
kind: 'unresolved',
|
||||
reason: 'arch_not_spoken',
|
||||
candidates: ['upper', 'lower'],
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'unresolved', reason: 'malformed' };
|
||||
}
|
||||
|
||||
function archSentinels(arch: 'upper' | 'lower' | 'both'): string[] {
|
||||
if (arch === 'upper') return [ARCH_TOOTH_UPPER];
|
||||
if (arch === 'lower') return [ARCH_TOOTH_LOWER];
|
||||
return [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER];
|
||||
}
|
||||
|
||||
type TypeClassification =
|
||||
| { kind: 'leaf'; code: string; chartRegion: string }
|
||||
| {
|
||||
kind: 'category' | 'subcategory';
|
||||
code: string;
|
||||
leafCodes: string[];
|
||||
regions: Set<string>;
|
||||
}
|
||||
| { kind: 'unknown' };
|
||||
|
||||
/** `types[]` may hold a leaf, or one category / subcategory code — the namespaces are disjoint. */
|
||||
function classifyTypeCode(
|
||||
rawCode: unknown,
|
||||
ctx: ResolveContext,
|
||||
): TypeClassification {
|
||||
if (typeof rawCode !== 'string' || !rawCode.trim())
|
||||
return { kind: 'unknown' };
|
||||
const code = rawCode.trim();
|
||||
|
||||
const leaf = ctx.prosthesisLeaves.find((l) => l.code === code);
|
||||
if (leaf) return { kind: 'leaf', code, chartRegion: leaf.chartRegion };
|
||||
|
||||
if (ctx.prosthesisCategoryCodes.has(code)) {
|
||||
const leaves = ctx.prosthesisLeaves.filter((l) => l.category === code);
|
||||
return {
|
||||
kind: 'category',
|
||||
code,
|
||||
leafCodes: leaves.map((l) => l.code),
|
||||
regions: new Set(leaves.map((l) => l.chartRegion)),
|
||||
};
|
||||
}
|
||||
if (ctx.prosthesisSubcategoryCodes.has(code)) {
|
||||
const leaves = ctx.prosthesisLeaves.filter((l) => l.subcategory === code);
|
||||
return {
|
||||
kind: 'subcategory',
|
||||
code,
|
||||
leafCodes: leaves.map((l) => l.code),
|
||||
regions: new Set(leaves.map((l) => l.chartRegion)),
|
||||
};
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
const TOOTH_REGIONS = new Set(['crown', 'root']);
|
||||
const JAW_REGIONS = new Set(['arch']);
|
||||
|
||||
function acceptableRegions(kind: 'tooth' | 'jaw'): ReadonlySet<string> {
|
||||
return kind === 'tooth' ? TOOTH_REGIONS : JAW_REGIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* One spoken instruction, resolved. Stack legality (`canStackLeaf`, screw-retained exclusion)
|
||||
* is never checked here — that lives in the frontend's `prosthesisTree.ts` (decision 38); this
|
||||
* only confirms a code exists and that its region suits its target.
|
||||
*/
|
||||
export function resolveProsthesisAssignment(
|
||||
assignment: ProsthesisAssignment,
|
||||
index: number,
|
||||
ctx: ResolveContext,
|
||||
unresolved: UnresolvedItem[],
|
||||
): ResolvedProsthesisAssignment {
|
||||
// Targets are resolved BEFORE types, because the target kind is what narrows a spoken
|
||||
// category. "پروتز متحرک برای دندون ۱۲" must offer only `partial_denture` — the one
|
||||
// `removable` leaf with a tooth region — not the whole category. Deferring the region check
|
||||
// until a leaf is picked (the earlier reading of decision 49) left nothing to complete it,
|
||||
// and wrote a complete denture onto a single tooth.
|
||||
const rawTargets = Array.isArray(assignment?.targets)
|
||||
? assignment.targets
|
||||
: [];
|
||||
for (const override of overrides) {
|
||||
const tooth = resolveToothIntent(override?.tooth);
|
||||
const type = resolveCatalogCode(override?.type, allowed);
|
||||
const spoken = spokenOf(override?.tooth) || String(override?.type ?? '');
|
||||
if (!tooth) {
|
||||
unresolved.push({ spoken, reason: 'malformed' });
|
||||
const resolvedTargets: {
|
||||
kind: 'tooth' | 'jaw';
|
||||
spoken: string;
|
||||
codes: string[];
|
||||
}[] = [];
|
||||
|
||||
for (const rawTarget of rawTargets) {
|
||||
const resolution = resolveAssignmentTarget(rawTarget);
|
||||
const spoken = spokenOf(rawTarget);
|
||||
|
||||
if (resolution.kind === 'unresolved') {
|
||||
unresolved.push(
|
||||
resolution.candidates
|
||||
? {
|
||||
spoken,
|
||||
reason: resolution.reason,
|
||||
candidates: resolution.candidates,
|
||||
assignmentIndex: index,
|
||||
}
|
||||
: { spoken, reason: resolution.reason, assignmentIndex: index },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// A tooth we understood perfectly well but which is not part of this detail. Saying
|
||||
// so is actionable ("add tooth 37, or drop it"); calling it malformed is not.
|
||||
if (!selected.has(tooth)) {
|
||||
unresolved.push({ spoken, reason: 'tooth_not_selected' });
|
||||
continue;
|
||||
}
|
||||
if (!type) {
|
||||
unresolved.push({ spoken, reason: 'unknown_catalog_code' });
|
||||
continue;
|
||||
}
|
||||
byTooth[tooth] = type;
|
||||
|
||||
resolvedTargets.push(
|
||||
resolution.kind === 'tooth'
|
||||
? { kind: 'tooth', spoken, codes: [resolution.fdi] }
|
||||
: { kind: 'jaw', spoken, codes: archSentinels(resolution.arch) },
|
||||
);
|
||||
}
|
||||
|
||||
// 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 };
|
||||
// The regions any resolved target of this assignment can carry. Empty means no target
|
||||
// resolved yet — a bare "نایت گارد" with no jaw — and then nothing can be narrowed or
|
||||
// validated, so every leaf stays on offer.
|
||||
const allowedRegions = new Set<string>();
|
||||
for (const target of resolvedTargets) {
|
||||
for (const region of acceptableRegions(target.kind))
|
||||
allowedRegions.add(region);
|
||||
}
|
||||
const suits = (region: string) =>
|
||||
allowedRegions.size === 0 || allowedRegions.has(region);
|
||||
|
||||
const rawTypes = Array.isArray(assignment?.types) ? assignment.types : [];
|
||||
const leafCodes = new Set<string>();
|
||||
const leafRegions = new Set<string>();
|
||||
// Set when a named type cannot suit this assignment's targets at all. The targets are then
|
||||
// dropped without a second report — one `code_not_valid_for_target` per contradiction, not
|
||||
// one per target — so the assignment applies nothing rather than half of what was said.
|
||||
let regionConflict = false;
|
||||
|
||||
for (const rawCode of rawTypes) {
|
||||
const classification = classifyTypeCode(rawCode, ctx);
|
||||
|
||||
if (classification.kind === 'leaf') {
|
||||
leafCodes.add(classification.code);
|
||||
leafRegions.add(classification.chartRegion);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
classification.kind === 'category' ||
|
||||
classification.kind === 'subcategory'
|
||||
) {
|
||||
const leaves = classification.leafCodes.filter((code) => {
|
||||
const leaf = ctx.prosthesisLeaves.find((l) => l.code === code);
|
||||
return leaf ? suits(leaf.chartRegion) : false;
|
||||
});
|
||||
|
||||
// A category with no leaf this target can carry is a contradiction, not an ambiguity:
|
||||
// `implant` aimed at a jaw, or `appliance` aimed at a tooth.
|
||||
if (leaves.length === 0) {
|
||||
unresolved.push({
|
||||
spoken: spokenOf(assignment) || classification.code,
|
||||
reason: 'code_not_valid_for_target',
|
||||
assignmentIndex: index,
|
||||
});
|
||||
regionConflict = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
unresolved.push({
|
||||
spoken: spokenOf(assignment) || classification.code,
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: [...leaves].sort(),
|
||||
assignmentIndex: index,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof rawCode === 'string' && rawCode.trim()) {
|
||||
unresolved.push({
|
||||
spoken: rawCode,
|
||||
reason: 'unknown_catalog_code',
|
||||
assignmentIndex: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const targets = new Set<string>();
|
||||
|
||||
for (const target of resolvedTargets) {
|
||||
// EVERY named leaf must suit the target, not merely one of them. `some` let a legal
|
||||
// crown carry an arch-only appliance onto the same tooth.
|
||||
if (regionConflict) continue;
|
||||
const accepted = acceptableRegions(target.kind);
|
||||
const valid = [...leafRegions].every((region) => accepted.has(region));
|
||||
if (!valid) {
|
||||
unresolved.push({
|
||||
spoken: target.spoken,
|
||||
reason: 'code_not_valid_for_target',
|
||||
assignmentIndex: index,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const code of target.codes) targets.add(code);
|
||||
}
|
||||
|
||||
const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
|
||||
return {
|
||||
prosthesis: {
|
||||
byTooth,
|
||||
complete: missingTeeth.length === 0,
|
||||
missingTeeth,
|
||||
},
|
||||
unresolved,
|
||||
targets: [...targets],
|
||||
types: [...leafCodes],
|
||||
spoken: spokenOf(assignment),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole jaw is not a tooth, and the model often names one in `teeth` as well as in the
|
||||
* prosthesis target it belongs to. An arch with no position then reaches `unresolvedReason`,
|
||||
* which reports `position_out_of_range` — a range fault for a value that was never a number —
|
||||
* and the sheet asks the clinician to repair a tooth nobody said. The jaw already reaches the
|
||||
* form through its assignment, so drop the duplicate instead.
|
||||
*
|
||||
* A position that IS given stays: arch + position without a side is a real tooth described
|
||||
* without its quadrant, and must keep offering its candidate chips.
|
||||
*/
|
||||
function isJawReference(intent: ToothIntent): boolean {
|
||||
if (intent?.kind !== 'positional') return false;
|
||||
const archGiven =
|
||||
intent.arch === 'upper' ||
|
||||
intent.arch === 'lower' ||
|
||||
intent.arch === 'both';
|
||||
return archGiven && !Number.isInteger(intent.position);
|
||||
}
|
||||
|
||||
/** Compose every resolver into the payload the review sheet renders. */
|
||||
export function resolveVoiceIntent(
|
||||
intent: VoiceIntent,
|
||||
@@ -242,7 +507,9 @@ export function resolveVoiceIntent(
|
||||
): ResolvedExtraction {
|
||||
const unresolved: UnresolvedItem[] = [];
|
||||
|
||||
const toothResult = resolveToothIntents(intent?.teeth ?? []);
|
||||
const toothResult = resolveToothIntents(
|
||||
(intent?.teeth ?? []).filter((tooth) => !isJawReference(tooth)),
|
||||
);
|
||||
unresolved.push(...toothResult.unresolved);
|
||||
|
||||
const spanResult = resolveConnectedSpans(
|
||||
@@ -262,18 +529,31 @@ export function resolveVoiceIntent(
|
||||
});
|
||||
}
|
||||
|
||||
const prosthesisResult = resolveProsthesis(
|
||||
intent?.prosthesis,
|
||||
spanResult.teeth,
|
||||
ctx.prosthesisTypeCodes,
|
||||
const rawAssignments = Array.isArray(intent?.prosthesis)
|
||||
? intent.prosthesis
|
||||
: [];
|
||||
const prosthesisAssignments = rawAssignments.map((assignment, index) =>
|
||||
resolveProsthesisAssignment(assignment, index, ctx, unresolved),
|
||||
);
|
||||
|
||||
// `prosthesis` is the only labDependent treatment type — any assignment that actually
|
||||
// landed on a target forces it, and the sheet locks that row while it is ticked (decision 41).
|
||||
const hasResolvedAssignment = prosthesisAssignments.some(
|
||||
(a) => a.targets.length > 0,
|
||||
);
|
||||
unresolved.push(...prosthesisResult.unresolved);
|
||||
|
||||
const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
|
||||
if (due.unresolved) unresolved.push(due.unresolved);
|
||||
|
||||
// A note is written only when the clinician asked for one. The model reports the words that
|
||||
// asked ("بنویس که", "write this in the notes"); without them, whatever it put in `comment`
|
||||
// is leftover speech it decided was a note, and it is dropped. Matching the phrase itself
|
||||
// stays in the prompt, so this check needs no per-locale vocabulary.
|
||||
const commentAsked =
|
||||
typeof intent?.commentTrigger === 'string' &&
|
||||
intent.commentTrigger.trim().length > 0;
|
||||
const comment =
|
||||
typeof intent?.comment === 'string' && intent.comment.trim()
|
||||
commentAsked && typeof intent?.comment === 'string' && intent.comment.trim()
|
||||
? intent.comment.trim()
|
||||
: null;
|
||||
|
||||
@@ -287,11 +567,11 @@ export function resolveVoiceIntent(
|
||||
}
|
||||
|
||||
return {
|
||||
treatmentType,
|
||||
treatmentType: hasResolvedAssignment ? 'prosthesis' : treatmentType,
|
||||
teeth: spanResult.teeth,
|
||||
toothSelectionGroups: spanResult.groups,
|
||||
comment,
|
||||
prosthesis: prosthesisResult.prosthesis,
|
||||
prosthesisAssignments,
|
||||
labId,
|
||||
labMatchExact: labId ? intent?.labMatchExact === true : false,
|
||||
dueDate: due.dueDate,
|
||||
|
||||
@@ -23,8 +23,8 @@ const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
|
||||
teeth: [],
|
||||
connectedSpans: [],
|
||||
comment: null,
|
||||
prosthesisDefaultType: null,
|
||||
prosthesisOverrides: [],
|
||||
commentTrigger: null,
|
||||
prosthesis: [],
|
||||
labId: null,
|
||||
labMatchExact: false,
|
||||
due: emptyDue,
|
||||
@@ -177,26 +177,70 @@ describe('toVoiceIntent', () => {
|
||||
expect(result.due).toEqual({ kind: 'lunar_month' });
|
||||
});
|
||||
|
||||
it('reports no prosthesis when neither a default nor an override was given', () => {
|
||||
expect(toVoiceIntent(wire()).prosthesis).toBeNull();
|
||||
it('yields an empty array, never null, when nothing was spoken about prosthesis', () => {
|
||||
expect(toVoiceIntent(wire()).prosthesis).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds a prosthesis intent from a default alone', () => {
|
||||
const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' }));
|
||||
expect(result.prosthesis).toEqual({
|
||||
defaultType: 'pfm_crown',
|
||||
overrides: [],
|
||||
it('narrows a prosthesis assignment, targets and types alike', () => {
|
||||
const result = toVoiceIntent(
|
||||
wire({
|
||||
prosthesis: [
|
||||
{
|
||||
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(
|
||||
wire({
|
||||
prosthesisOverrides: [{ tooth: positionalTooth, type: 'pfm_crown' }],
|
||||
}),
|
||||
wire({ prosthesis: [{ targets: 'nope', types: 5 } as never] }),
|
||||
);
|
||||
expect(result.prosthesis?.defaultType).toBeNull();
|
||||
expect(result.prosthesis?.overrides).toHaveLength(1);
|
||||
expect(result.prosthesis).toEqual([{ targets: [], types: [], spoken: '' }]);
|
||||
});
|
||||
|
||||
it('narrows connected spans', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { normalizeFdiCode } from '../../common/fdi';
|
||||
import type {
|
||||
ConnectedSpanIntent,
|
||||
DueIntent,
|
||||
ProsthesisIntent,
|
||||
ProsthesisAssignment,
|
||||
ToothIntent,
|
||||
VoiceIntent,
|
||||
Weekday,
|
||||
@@ -20,7 +20,7 @@ export type WireToothIntent = {
|
||||
spoken: string;
|
||||
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */
|
||||
fdi: string | null;
|
||||
arch: 'upper' | 'lower' | null;
|
||||
arch: 'upper' | 'lower' | 'both' | null;
|
||||
side: 'patient_right' | 'patient_left' | null;
|
||||
position: number | null;
|
||||
};
|
||||
@@ -39,13 +39,19 @@ export type WireDue = {
|
||||
d: number | null;
|
||||
};
|
||||
|
||||
export type WireProsthesisAssignment = {
|
||||
targets: WireToothIntent[];
|
||||
types: string[];
|
||||
spoken: string;
|
||||
};
|
||||
|
||||
export type WireVoiceIntent = {
|
||||
treatmentType: string | null;
|
||||
teeth: WireToothIntent[];
|
||||
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
|
||||
comment: string | null;
|
||||
prosthesisDefaultType: string | null;
|
||||
prosthesisOverrides: { tooth: WireToothIntent; type: string }[];
|
||||
commentTrigger: string | null;
|
||||
prosthesis: WireProsthesisAssignment[];
|
||||
labId: string | null;
|
||||
labMatchExact: boolean;
|
||||
due: WireDue;
|
||||
@@ -58,15 +64,21 @@ const TOOTH_SCHEMA = {
|
||||
properties: {
|
||||
spoken: {
|
||||
type: 'string',
|
||||
description: 'The exact transcript words for this tooth.',
|
||||
description: 'The exact transcript words for this tooth (or jaw).',
|
||||
},
|
||||
fdi: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
|
||||
'when the tooth was described in words instead of numbered.',
|
||||
'when the tooth was described in words instead of numbered, or the target is a jaw.',
|
||||
},
|
||||
arch: {
|
||||
type: ['string', 'null'],
|
||||
enum: ['upper', 'lower', 'both', null],
|
||||
description:
|
||||
'"both" is only ever used for a jaw-level prosthesis target (e.g. an appliance for ' +
|
||||
'both jaws), never for a single tooth.',
|
||||
},
|
||||
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
|
||||
side: {
|
||||
type: ['string', 'null'],
|
||||
enum: ['patient_right', 'patient_left', null],
|
||||
@@ -75,7 +87,33 @@ const TOOTH_SCHEMA = {
|
||||
position: {
|
||||
type: ['integer', 'null'],
|
||||
description:
|
||||
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
|
||||
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI ' +
|
||||
'code. Null when this target is a jaw rather than a tooth.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROSTHESIS_ASSIGNMENT_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['targets', 'types', 'spoken'],
|
||||
properties: {
|
||||
targets: {
|
||||
type: 'array',
|
||||
description: 'The teeth or jaws this instruction applies to.',
|
||||
items: TOOTH_SCHEMA,
|
||||
},
|
||||
types: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Prosthesis type codes to apply to every target above — a stack, e.g. an abutment ' +
|
||||
'plus a crown on the same tooth. A code from the CATEGORY or SUBCATEGORY lists is ' +
|
||||
'fine when only the general term was said.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
spoken: {
|
||||
type: 'string',
|
||||
description: 'The exact transcript words for this instruction.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -88,8 +126,8 @@ export const VOICE_INTENT_JSON_SCHEMA = {
|
||||
'teeth',
|
||||
'connectedSpans',
|
||||
'comment',
|
||||
'prosthesisDefaultType',
|
||||
'prosthesisOverrides',
|
||||
'commentTrigger',
|
||||
'prosthesis',
|
||||
'labId',
|
||||
'labMatchExact',
|
||||
'due',
|
||||
@@ -99,7 +137,13 @@ export const VOICE_INTENT_JSON_SCHEMA = {
|
||||
type: ['string', 'null'],
|
||||
description: 'A treatment type CODE from the supplied list, or null.',
|
||||
},
|
||||
teeth: { type: 'array', items: TOOTH_SCHEMA },
|
||||
teeth: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Individual teeth only. A whole jaw NEVER belongs here — put it in ' +
|
||||
'prosthesis[].targets with "arch" set and "position" null.',
|
||||
items: TOOTH_SCHEMA,
|
||||
},
|
||||
connectedSpans: {
|
||||
type: 'array',
|
||||
description: 'Bridges / splinted units. Endpoints inclusive.',
|
||||
@@ -112,21 +156,24 @@ export const VOICE_INTENT_JSON_SCHEMA = {
|
||||
},
|
||||
comment: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Clinical notes, in the spoken language.',
|
||||
description:
|
||||
'The note the clinician explicitly dictated, in the spoken language, WITHOUT the ' +
|
||||
'words that asked for it. Null unless they actually asked for a note. Never put ' +
|
||||
'leftover speech here.',
|
||||
},
|
||||
prosthesisDefaultType: {
|
||||
commentTrigger: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'A prosthesis type CODE applied to every tooth unless overridden.',
|
||||
'The exact words that asked for a note, copied from the transcript (e.g. ' +
|
||||
'"بنویس که", "write this in the notes"). Null when nobody asked. A comment with ' +
|
||||
'no trigger is discarded.',
|
||||
},
|
||||
prosthesisOverrides: {
|
||||
prosthesis: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['tooth', 'type'],
|
||||
properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } },
|
||||
},
|
||||
description:
|
||||
'One entry per spoken instruction: these targets get these jobs. No default and no ' +
|
||||
'overrides — every entry names its own targets.',
|
||||
items: PROSTHESIS_ASSIGNMENT_SCHEMA,
|
||||
},
|
||||
labId: {
|
||||
type: ['string', 'null'],
|
||||
@@ -190,7 +237,7 @@ function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
|
||||
}
|
||||
return {
|
||||
kind: 'positional',
|
||||
arch: wire?.arch as 'upper' | 'lower',
|
||||
arch: wire?.arch as 'upper' | 'lower' | 'both',
|
||||
side: wire?.side as 'patient_right' | 'patient_left',
|
||||
position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
|
||||
spoken,
|
||||
@@ -236,36 +283,35 @@ function toDueIntent(wire: WireDue | undefined | null): DueIntent | null {
|
||||
}
|
||||
}
|
||||
|
||||
function toProsthesisAssignment(
|
||||
wire: WireProsthesisAssignment | undefined | null,
|
||||
): ProsthesisAssignment {
|
||||
const targets = Array.isArray(wire?.targets) ? wire.targets : [];
|
||||
const types = Array.isArray(wire?.types) ? wire.types : [];
|
||||
return {
|
||||
targets: targets.map(toToothIntent),
|
||||
types: types.filter((code): code is string => typeof code === 'string'),
|
||||
spoken: typeof wire?.spoken === 'string' ? wire.spoken : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
|
||||
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
|
||||
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
|
||||
const overrides = Array.isArray(wire?.prosthesisOverrides)
|
||||
? wire.prosthesisOverrides
|
||||
: [];
|
||||
const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : [];
|
||||
|
||||
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
|
||||
from: toToothIntent(span?.from),
|
||||
to: toToothIntent(span?.to),
|
||||
}));
|
||||
|
||||
const hasProsthesis =
|
||||
wire?.prosthesisDefaultType != null || overrides.length > 0;
|
||||
const prosthesis: ProsthesisIntent | null = hasProsthesis
|
||||
? {
|
||||
defaultType: wire?.prosthesisDefaultType ?? null,
|
||||
overrides: overrides.map((o) => ({
|
||||
tooth: toToothIntent(o?.tooth),
|
||||
type: o?.type,
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
treatmentType: wire?.treatmentType ?? null,
|
||||
teeth: teeth.map(toToothIntent),
|
||||
connectedSpans,
|
||||
comment: wire?.comment ?? null,
|
||||
prosthesis,
|
||||
commentTrigger: wire?.commentTrigger ?? null,
|
||||
prosthesis: assignments.map(toProsthesisAssignment),
|
||||
labId: wire?.labId ?? null,
|
||||
labMatchExact: wire?.labMatchExact === true,
|
||||
due: toDueIntent(wire?.due),
|
||||
|
||||
@@ -12,7 +12,18 @@ const CONFIG = {
|
||||
|
||||
const CATALOG = {
|
||||
treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }],
|
||||
prosthesisTypes: [{ code: 'pfm_crown', label: 'روکش پیافام' }],
|
||||
prosthesisTypes: [
|
||||
{
|
||||
code: 'pfm_crown',
|
||||
label: 'روکش پیافام',
|
||||
category: 'crown',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
stackGroup: 'restoration',
|
||||
},
|
||||
],
|
||||
prosthesisCategories: [{ code: 'crown', label: 'روکشها' }],
|
||||
prosthesisSubcategories: [],
|
||||
labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }],
|
||||
};
|
||||
|
||||
@@ -111,8 +122,21 @@ describe('OpenRouterExtractionProvider', () => {
|
||||
],
|
||||
connectedSpans: [],
|
||||
comment: null,
|
||||
prosthesisDefaultType: 'pfm_crown',
|
||||
prosthesisOverrides: [],
|
||||
prosthesis: [
|
||||
{
|
||||
targets: [
|
||||
{
|
||||
spoken: 'یک چهار',
|
||||
fdi: '14',
|
||||
arch: null,
|
||||
side: null,
|
||||
position: null,
|
||||
},
|
||||
],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'روکش پیافام روی ۱۴',
|
||||
},
|
||||
],
|
||||
labId: 'lab-1',
|
||||
labMatchExact: true,
|
||||
due: {
|
||||
|
||||
@@ -34,7 +34,22 @@ export interface AsrProvider {
|
||||
export type ExtractionCatalog = {
|
||||
/** Catalog codes with their labels in the actor's locale, so the model matches spoken words. */
|
||||
treatmentTypes: { code: string; label: string }[];
|
||||
prosthesisTypes: { code: string; label: string }[];
|
||||
/**
|
||||
* Leaf prosthesis types — the full tree shape, not just code/label, so the prompt can
|
||||
* present it as a tree and mark which codes are jaw-level (`chartRegion: 'arch'`).
|
||||
*/
|
||||
prosthesisTypes: {
|
||||
code: string;
|
||||
label: string;
|
||||
category: string;
|
||||
subcategory: string;
|
||||
chartRegion: string;
|
||||
stackGroup: string;
|
||||
}[];
|
||||
/** The 7 category codes, so "روکش" resolves to `crown` rather than a guessed leaf. */
|
||||
prosthesisCategories: { code: string; label: string }[];
|
||||
/** The 5 subcategory codes (veneer, inlay, onlay, overlay, night_guard). */
|
||||
prosthesisSubcategories: { code: string; label: string }[];
|
||||
/** The clinic's linked labs — a closed choice list. */
|
||||
labs: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
@@ -35,9 +35,12 @@ export type VoiceAvailability = {
|
||||
maxRecordingMs: number | null;
|
||||
};
|
||||
|
||||
export type VoiceExtractionResponse = ResolvedExtraction & {
|
||||
transcript: string;
|
||||
};
|
||||
/**
|
||||
* The transcript is deliberately absent. A raw dictation can carry the patient's spoken name, so
|
||||
* it never leaves the server — it is logged there instead (§10). Nothing the client renders needs
|
||||
* it, and what is not sent cannot leak through the network tab or an error reporter.
|
||||
*/
|
||||
export type VoiceExtractionResponse = ResolvedExtraction;
|
||||
|
||||
@Injectable()
|
||||
export class VoiceService {
|
||||
@@ -119,13 +122,20 @@ export class VoiceService {
|
||||
);
|
||||
}
|
||||
|
||||
// Stage 2 — structure it. On failure the transcript still goes back to the client so
|
||||
// the words the clinician already paid for are not lost (transcript salvage).
|
||||
// The transcript's only destination. Logged before extraction so it survives an extraction
|
||||
// failure too, and kept out of logTelemetry so that method's patient-free guarantee stays
|
||||
// true. This line DOES carry what the clinician said, which may include a patient's name.
|
||||
this.logger.log(
|
||||
`voice transcript [${catalogLocale}]: ${transcript.trim()}`,
|
||||
);
|
||||
|
||||
// Stage 2 — structure it. A failure here returns a code only; the transcript stays in the
|
||||
// server log above, never in the response.
|
||||
let resolved: ResolvedExtraction;
|
||||
let llmCost: number | null = null;
|
||||
try {
|
||||
// Inside the try: the transcript is already paid for, so a catalog/DB failure here
|
||||
// must still salvage it rather than becoming a generic 500 that throws it away.
|
||||
// Inside the try: a catalog/DB failure here must surface as VOICE_EXTRACT_FAILED, which
|
||||
// the clinician can act on, rather than a generic 500.
|
||||
const catalog = await this.buildCatalog(organizationId, catalogLocale);
|
||||
const result = await extraction.extract(
|
||||
transcript,
|
||||
@@ -138,13 +148,17 @@ export class VoiceService {
|
||||
todayIso,
|
||||
weekStartJs: weekStartForLocale(catalogLocale),
|
||||
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
|
||||
prosthesisTypeCodes: new Set(
|
||||
catalog.prosthesisTypes.map((t) => t.code),
|
||||
prosthesisLeaves: catalog.prosthesisTypes,
|
||||
prosthesisCategoryCodes: new Set(
|
||||
catalog.prosthesisCategories.map((c) => c.code),
|
||||
),
|
||||
prosthesisSubcategoryCodes: new Set(
|
||||
catalog.prosthesisSubcategories.map((c) => c.code),
|
||||
),
|
||||
linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.toAppException(error, 'extraction', transcript);
|
||||
throw this.toAppException(error, 'extraction');
|
||||
}
|
||||
|
||||
this.logTelemetry({
|
||||
@@ -156,7 +170,7 @@ export class VoiceService {
|
||||
resolved,
|
||||
});
|
||||
|
||||
return { ...resolved, transcript };
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private assertOrganization(user: { organizationId?: string }): string {
|
||||
@@ -244,9 +258,17 @@ export class VoiceService {
|
||||
organizationId: string,
|
||||
locale: string,
|
||||
): Promise<ExtractionCatalog> {
|
||||
const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([
|
||||
const [
|
||||
treatmentTypes,
|
||||
prosthesisTypes,
|
||||
prosthesisCategories,
|
||||
prosthesisSubcategories,
|
||||
labs,
|
||||
] = await Promise.all([
|
||||
this.treatmentCatalog.list(locale, null),
|
||||
this.prosthesisCatalog.list(locale),
|
||||
this.prosthesisCatalog.listCategories(locale),
|
||||
this.prosthesisCatalog.listSubcategories(locale),
|
||||
this.listLinkedLabs(organizationId),
|
||||
]);
|
||||
|
||||
@@ -254,10 +276,11 @@ export class VoiceService {
|
||||
treatmentTypes: treatmentTypes
|
||||
.filter((entry) => entry.availableInTreatment)
|
||||
.map((entry) => ({ code: entry.code, label: entry.label })),
|
||||
prosthesisTypes: prosthesisTypes.map((entry) => ({
|
||||
code: entry.code,
|
||||
label: entry.label,
|
||||
})),
|
||||
// `buildCatalog` used to throw away category/subcategory/chartRegion/stackGroup here —
|
||||
// the prompt now presents the catalog as the tree it is (§5).
|
||||
prosthesisTypes,
|
||||
prosthesisCategories,
|
||||
prosthesisSubcategories,
|
||||
labs,
|
||||
};
|
||||
}
|
||||
@@ -290,7 +313,6 @@ export class VoiceService {
|
||||
private toAppException(
|
||||
error: unknown,
|
||||
stage: 'asr' | 'extraction',
|
||||
transcript?: string,
|
||||
): AppException {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
// The clinician cancelled; not a failure worth a translated message.
|
||||
@@ -305,11 +327,9 @@ export class VoiceService {
|
||||
stage === 'asr'
|
||||
? ErrorCode.VOICE_ASR_FAILED
|
||||
: ErrorCode.VOICE_EXTRACT_FAILED;
|
||||
return new AppException(
|
||||
code,
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
transcript ? { transcript } : undefined,
|
||||
);
|
||||
// No details: the transcript used to ride along here for a salvage dialog that was never
|
||||
// built, so it was serialized onto the wire and dropped. It stays on the server now.
|
||||
return new AppException(code, HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
/** Structured and patient-free: never the transcript, never audio, never a patient id. */
|
||||
@@ -333,7 +353,7 @@ export class VoiceService {
|
||||
treatmentType: resolved.treatmentType != null,
|
||||
teeth: resolved.teeth.length,
|
||||
comment: resolved.comment != null,
|
||||
prosthesisComplete: resolved.prosthesis?.complete ?? null,
|
||||
prosthesisAssignments: resolved.prosthesisAssignments.length,
|
||||
lab: resolved.labId != null,
|
||||
dueDate: resolved.dueDate != null,
|
||||
},
|
||||
|
||||
@@ -8,14 +8,19 @@ import type { Arch, PatientSide } from '../../common/fdi';
|
||||
* mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful.
|
||||
*/
|
||||
|
||||
/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */
|
||||
/**
|
||||
* A single spoken tooth reference, OR — inside a prosthesis assignment's `targets` — a jaw.
|
||||
* Nothing on the wire declares "this is a jaw": a positional reference with no `position` at
|
||||
* all names an arch instead of a tooth, and the resolver tells the two apart (§5, §6).
|
||||
* `arch: 'both'` only ever appears here, never on a resolved single tooth.
|
||||
*/
|
||||
export type ToothIntent =
|
||||
| { kind: 'explicit'; fdi: string; spoken: string }
|
||||
| {
|
||||
kind: 'positional';
|
||||
arch: Arch;
|
||||
arch: Arch | 'both';
|
||||
side: PatientSide;
|
||||
/** 1 = central incisor … 8 = third molar. */
|
||||
/** 1 = central incisor … 8 = third molar. Absent (NaN) when the target is a jaw. */
|
||||
position: number;
|
||||
spoken: string;
|
||||
};
|
||||
@@ -42,10 +47,18 @@ export type Weekday = (typeof WEEKDAYS)[number];
|
||||
/** Two teeth defining an inclusive connected (bridge) span. */
|
||||
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
|
||||
|
||||
export type ProsthesisIntent = {
|
||||
/** Catalog code applied to every tooth unless overridden. */
|
||||
defaultType: string | null;
|
||||
overrides: { tooth: ToothIntent; type: string }[];
|
||||
/**
|
||||
* One spoken instruction: these targets get these jobs. Replaces the old
|
||||
* `prosthesisDefaultType` + `prosthesisOverrides` pair — a default with per-tooth overrides
|
||||
* has a precedence rule, and a precedence rule has a wrong side (decision 35).
|
||||
*/
|
||||
export type ProsthesisAssignment = {
|
||||
/** Each one a tooth or a jaw — see `ToothIntent`. */
|
||||
targets: ToothIntent[];
|
||||
/** Leaf codes, or one category / subcategory code the clinician named generically. */
|
||||
types: string[];
|
||||
/** The transcript span, echoed back to the clinician. */
|
||||
spoken: string;
|
||||
};
|
||||
|
||||
export type VoiceIntent = {
|
||||
@@ -53,7 +66,14 @@ export type VoiceIntent = {
|
||||
teeth: ToothIntent[];
|
||||
connectedSpans: ConnectedSpanIntent[];
|
||||
comment: string | null;
|
||||
prosthesis: ProsthesisIntent | null;
|
||||
/**
|
||||
* The exact spoken words that asked for a note — "بنویس که", "write this in the notes".
|
||||
* Null when nothing asked. The resolver keeps `comment` only when this is present, so the
|
||||
* model cannot decide on its own that leftover speech was a note.
|
||||
*/
|
||||
commentTrigger: string | null;
|
||||
/** Empty array, never null. */
|
||||
prosthesis: ProsthesisAssignment[];
|
||||
/** Must be one of the linked-lab ids supplied in the prompt, or null. */
|
||||
labId: string | null;
|
||||
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */
|
||||
@@ -67,10 +87,15 @@ export type UnresolvedReason =
|
||||
| 'position_out_of_range'
|
||||
/** A position was understood but no quadrant was spoken — four teeth match. */
|
||||
| 'tooth_missing_quadrant'
|
||||
/** A category or subcategory was heard, not a material. */
|
||||
| 'prosthesis_type_ambiguous'
|
||||
/** A jaw-level appliance with no jaw spoken. */
|
||||
| 'arch_not_spoken'
|
||||
/** An arch code aimed at a tooth, or a tooth code aimed at a jaw. */
|
||||
| 'code_not_valid_for_target'
|
||||
| 'malformed'
|
||||
| 'span_not_same_arch'
|
||||
| 'unknown_catalog_code'
|
||||
| 'tooth_not_selected'
|
||||
| 'invalid_date';
|
||||
|
||||
export type UnresolvedItem = {
|
||||
@@ -78,8 +103,15 @@ export type UnresolvedItem = {
|
||||
spoken: string;
|
||||
reason: UnresolvedReason;
|
||||
/**
|
||||
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
|
||||
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
|
||||
* Values still consistent with what was heard: FDI codes for `tooth_missing_quadrant`,
|
||||
* leaf codes for `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`. The
|
||||
* sheet offers these as chips.
|
||||
*/
|
||||
candidates?: string[];
|
||||
/**
|
||||
* Set only when this item was raised while resolving a `prosthesis` assignment's targets or
|
||||
* types. A picked chip then inherits that assignment's `types[]` (or supplies the missing
|
||||
* leaf to it) instead of resolving to a jobless tooth (decision 50).
|
||||
*/
|
||||
assignmentIndex?: number;
|
||||
};
|
||||
|
||||
532
docs/specs/voice-treatment-entry/progress.md
Normal file
532
docs/specs/voice-treatment-entry/progress.md
Normal file
@@ -0,0 +1,532 @@
|
||||
---
|
||||
type: task-progress
|
||||
status: active
|
||||
repos:
|
||||
- repo: dyolink
|
||||
path: ~/PersonalProjects/dyolink
|
||||
branch: feat/treatment/add-voice-input-for-new-treatment-form
|
||||
base: origin/master
|
||||
role: extraction contract + resolvers + review sheet
|
||||
spec_slug: voice-treatment-entry
|
||||
merge_after: []
|
||||
---
|
||||
|
||||
# PROGRESS — Voice Treatment Entry
|
||||
|
||||
Spec: [spec.md](./spec.md)
|
||||
|
||||
Adapting the merged voice feature to the overhauled prosthesis model (spec §5–§7,
|
||||
decisions 34–46). v1 shipped on `master`; this branch revises it.
|
||||
|
||||
Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
|
||||
|
||||
| # | Work item | Repo | Status | Notes / refs |
|
||||
|---|-----------|------|--------|--------------|
|
||||
| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | ✅ | §9. `frontend/src/lib/voice/audioFormat.ts` — the final `return null` after the loop is now `return ''` |
|
||||
| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | ✅ | §2 render policy, `TreatmentWorkspace.tsx` |
|
||||
| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | ✅ | §12. `vitest@3.2.7`, `frontend/vitest.config.ts`, `npm run test` script; `CLAUDE.md` Tests section rewritten |
|
||||
| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | ✅ | §5. `voice.service.ts` now forwards the full `ProsthesisTypeCatalogEntry[]` plus category/subcategory lists |
|
||||
| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | ✅ | §5, `extraction.wire.ts` — `prosthesis: WireProsthesisAssignment[]` |
|
||||
| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | ✅ | §5, §6, `extraction.resolver.ts` — `resolveProsthesisAssignment`, `resolveAssignmentTarget`, `classifyTypeCode` |
|
||||
| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | ✅ | §6 reason table — `voice.types.ts` |
|
||||
| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | ✅ | §5. `resolveVoiceIntent` — `hasResolvedAssignment` |
|
||||
| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | ✅ | §5, `extraction.prompt.ts` — `prosthesisTree()` renders CATEGORY/SUBCATEGORY/leaf from data, no hardcoded catalog knowledge |
|
||||
| 10 | Backend Jest suites for items 6–8, including the namespace-disjointness assertion | `dyolink` | ✅ | §12. `extraction.resolver.spec.ts` — disjointness test reads `catalog-seed-data.ts`'s live `PROSTHESIS_TYPES`, not a written count |
|
||||
| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | ✅ | `types/voice.ts` — `VoiceProsthesisAssignment[]`, `assignmentIndex` |
|
||||
| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | ✅ | §6, §7 — `prosthesisTargetLines`, `joblessProsthesisTargets`, `withChosenArch`/`withChosenProsthesisLeaf` |
|
||||
| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | ✅ | §7. Merged "Teeth and prosthesis" row; tooth / jaw / material chip kinds |
|
||||
| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | ✅ | §6, §7 — via `prosthesisTargetLines`, which routes every stack through `applyLeafToJobs` |
|
||||
| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | ✅ | §12. 37 tests total, `npx vitest run` green |
|
||||
| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | ✅ | i18n is mandatory, not a follow-up — new `voiceUnresolved.*` reasons, `voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused`; retired `voiceProsthesisIncomplete` (all-or-nothing gone) |
|
||||
| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | 🟡 | Every machine gate green, migration and seed now applied and verified (2026-09-07 entry below). **Manual pass still not run** — Safari, iPad, and the stack / jaw / chip flows in `fa` |
|
||||
| 18 | `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` in `CatalogEntityKind`; migration; seed fa/en/nl translations | `dyolink` | ✅ | §5, decision 47. Applied via `migrate deploy` and seeded: enum carries both kinds, 7 categories + 5 subcategories per locale, `crown` → `روکشها` |
|
||||
| 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
|
||||
|
||||
Full table in the spec's §13, rows 34–46. The load-bearing ones:
|
||||
|
||||
- **One assignment list, no default type.** A default plus per-tooth overrides has a
|
||||
precedence rule, and that rule already mis-filled tooth 13 once.
|
||||
- **An arch target is derived from the code's `chartRegion`, not declared on the wire.**
|
||||
The catalog already answers it, and `partial_denture` already carries `chartRegion: 'crown'`.
|
||||
- **Stack rules stay frontend-only.** The backend's dispatch check knows nothing about
|
||||
stacking; porting `canStackLeaf` would make voice stricter than the manual path and create
|
||||
a second copy of a rule that must never disagree.
|
||||
- **Teeth and prosthesis merge into one sheet row for lab-dependent types.** Two ticks can
|
||||
save an empty detail today, because `persistDraft` prunes lab-dependent details to their jobs.
|
||||
- **Nothing is guessed.** A missing material, a missing jaw and a missing quadrant all become
|
||||
chips. A jobless tooth is named and left out.
|
||||
- **Categories become real catalog entities** (decision 47). Two new `CatalogEntityKind` values
|
||||
and seeded translations, rather than bare untranslated codes in the prompt on `fa`.
|
||||
- **A chip must inherit its assignment's jobs** (decision 50), or it resolves to a jobless tooth
|
||||
and the tap does nothing.
|
||||
|
||||
## Deviations from spec
|
||||
|
||||
(none yet)
|
||||
|
||||
## Next steps / open questions
|
||||
|
||||
- Re-run `/orchestrate --dry` with a `gapNote`, so the surveyor re-runs instead of replaying its
|
||||
cached `gaps_found` verdict.
|
||||
- Amin reviews and approves this spec revision. That approval is the only human gate before
|
||||
`/orchestrate --dry`.
|
||||
- Open items 15 and 16 in §11 are carried forward unchanged and are **not** in this branch:
|
||||
the pre-authentication body limit on the voice path, and transcript salvage still being
|
||||
specified but not built.
|
||||
- `feat/voice-treatment-entry` and `backup/pre-rebase-voice` are stale local branches from the
|
||||
v1 work. Safe to delete once this lands.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — Gap-check halted (nothing built, nothing pushed)
|
||||
|
||||
- **Phase:** Gap-check
|
||||
- **Reason:** the spec has 1 gap that blocks implementation — nothing was built.
|
||||
- **Repo:** dyolink @ `feat/treatment/add-voice-input-for-new-treatment-form`
|
||||
|
||||
### Blocking gap 1 — `missing_constraint` (dyolink)
|
||||
|
||||
**Summary:** The prompt must present prosthesis categories and subcategories so the model can
|
||||
return `crown` for "روکش", but no localized label for a category or subcategory exists anywhere
|
||||
the backend can read. `CatalogEntityKind` has only TREATMENT_TYPE / PROSTHESIS_TYPE /
|
||||
LAB_WORKFLOW_STEP, and the only category labels in the repo are frontend message keys
|
||||
(`category_crown`, `sub_night_guard`) plus a hardcoded single-locale map in a dev export script.
|
||||
|
||||
**Spec quotes:**
|
||||
|
||||
> **The prosthesis list is no longer flat.** `buildCatalog` already receives `category`, `subcategory`, `chartRegion` and `stackGroup` from `ProsthesisCatalogService` and throws all four away. It now passes them through, so the prompt presents the catalog as the tree it is and marks which codes are jaw-level. Still no catalog knowledge hardcoded in the prompt — the shape comes from the data.
|
||||
|
||||
> Treatment types and prosthesis types come from `CatalogLabelService` in the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of `prosthesis` and `monolithic_zirconia` rather than being asked to translate. Catalog entities store a stable `code` and no label — never hardcode a label.
|
||||
|
||||
> "روکش" is *crown* — a category with nine leaves, not a material. The model returns the category code it actually heard rather than guessing `pfm_crown`, and the review sheet offers the leaves as chips (§7).
|
||||
|
||||
**Why blocking:** Work item 9 ("Prompt: present the catalog as a tree") cannot be built.
|
||||
`ProsthesisCatalogService.list()` returns `category` and `subcategory` as bare codes with no label
|
||||
(prosthesis-catalog.service.ts:82-95), and `CatalogLabelService` cannot resolve them because
|
||||
`CatalogEntityKind` has no category kind (schema.prisma:331-335). The three ways out have
|
||||
different costs and one breaks an explicit repo rule, so a builder cannot choose: (a) add
|
||||
PROSTHESIS_CATEGORY / PROSTHESIS_SUBCATEGORY to the enum plus a migration and seeded translations
|
||||
— a schema change the spec never mentions; (b) hardcode a fa/en/nl label map in the backend, which
|
||||
the spec and CLAUDE.md both forbid; (c) send bare codes only, which makes the whole category
|
||||
feature unmeasured on the weakest locale.
|
||||
|
||||
**Question:** Where do the localized labels for the 7 prosthesis categories and 5 subcategories
|
||||
come from for the extraction prompt: a new `CatalogEntityKind` (PROSTHESIS_CATEGORY /
|
||||
PROSTHESIS_SUBCATEGORY) with a migration and seeded `CatalogTranslation` rows, or bare codes with
|
||||
no labels in the prompt?
|
||||
|
||||
**Suggested spec change:** Add to §5: "Category and subcategory labels have no backend source
|
||||
today — `CatalogEntityKind` covers only TREATMENT_TYPE, PROSTHESIS_TYPE and LAB_WORKFLOW_STEP, and
|
||||
`category_*` / `sub_*` exist only in `frontend/messages/*.json`. Add `PROSTHESIS_CATEGORY` and
|
||||
`PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind` (one migration), seed their fa/en/nl translations
|
||||
from the existing message keys, and resolve them through `CatalogLabelService` like every other
|
||||
catalog label."
|
||||
|
||||
**Evidence:** backend/prisma/schema.prisma:331; backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:82; frontend/messages/en.json:1124; backend/prisma/export-prosthesis-catalog.ts:16
|
||||
|
||||
### Non-blocking notes
|
||||
|
||||
**Note 1 — `ambiguity` (dyolink).** An unresolved item carries only `spoken`, `reason` and
|
||||
`candidates`, with no link back to the assignment it came from. On a prosthesis detail a
|
||||
`tooth_missing_quadrant` chip therefore resolves to a tooth with no job, which the "tooth with no
|
||||
job is named and left out" rule then discards — so tapping the chip does nothing.
|
||||
|
||||
> **A single digit alone is never resolved.** "دندون دو" names four teeth. It is reported as `tooth_missing_quadrant` **with the candidate codes attached**
|
||||
|
||||
> **A tooth with no job is named and left out.**
|
||||
|
||||
> Picking one folds the choice into the result, so an under-specified item is one tap from resolved instead of a dead end.
|
||||
|
||||
*Why not blocking:* a builder can add an assignment reference to the unresolved item. But §6's
|
||||
reason table reads as a complete contract and its "Carries" column names only `candidates`, so a
|
||||
builder following it literally ships a dead chip on exactly the prosthesis flow §12 asks them to
|
||||
test.
|
||||
*Question:* Should `VoiceUnresolvedItem` gain an assignment reference (for example
|
||||
`assignmentIndex`) so a chosen quadrant chip inherits that assignment's job stack?
|
||||
*Suggested spec change:* In §6's reason table, add: "`tooth_missing_quadrant` and
|
||||
`arch_not_spoken` also carry the index of the assignment they came from, so a picked chip inherits
|
||||
that assignment's `types[]` rather than becoming a jobless tooth."
|
||||
*Evidence:* frontend/src/types/voice.ts:15-25; frontend/src/components/treatment/voiceReviewRows.ts:66
|
||||
|
||||
**Note 2 — `ambiguity` (dyolink).** "The resolver confirms [jaw vs tooth] against the
|
||||
`chartRegion` of the assignment's codes" is undefined when `types[]` holds a category whose leaves
|
||||
have mixed regions. `removable` is exactly that: `complete_denture` and `overdenture` are
|
||||
`chartRegion: 'arch'`, `partial_denture` is `'crown'`.
|
||||
|
||||
> A target that names an arch and no position is a jaw, and the resolver confirms that against the `chartRegion` of the assignment's codes.
|
||||
|
||||
> `partial_denture` — a `removable` code that is nonetheless assigned per tooth — already ships with `chartRegion: 'crown'` in `catalog-seed-data.ts`, so the exception needs no special case either.
|
||||
|
||||
> A code whose region contradicts its target is reported, never coerced.
|
||||
|
||||
*Why not blocking:* deferring the region check until the ambiguity chip is picked is a reasonable
|
||||
default. Worth stating so the builder does not emit `code_not_valid_for_target` for a category that
|
||||
has not been narrowed yet.
|
||||
*Question:* When `types[]` holds a category whose leaves span both `crown` and `arch` regions (only
|
||||
`removable` today), is the region check deferred until the clinician picks a leaf chip?
|
||||
*Suggested spec change:* In §5 under "A target is a tooth or a jaw": "When `types[]` holds a
|
||||
category whose leaves have more than one `chartRegion`, the region check is deferred — the item
|
||||
resolves to `prosthesis_type_ambiguous` and the region is confirmed against the leaf the clinician
|
||||
picks."
|
||||
*Evidence:* backend/prisma/catalog-seed-data.ts:266-288
|
||||
|
||||
**Note 3 — `brownfield` (dyolink).** §3 names the endpoint `POST /treatments/voice-extract`; the
|
||||
shipped route is `POST /voice/extract` (`@Controller('voice')` + `@Post('extract')`), and
|
||||
`common/body-parsers.ts` hardcodes `/api/voice/extract` to select the 10 MB body limit.
|
||||
|
||||
> `POST /treatments/voice-extract`
|
||||
|
||||
*Why not blocking:* no work item asks to move the route. But if a builder aligned the route with
|
||||
§3, `isVoiceExtractPath` would stop matching and every real recording would 413 against the 100 kb
|
||||
default, which reads as a broken microphone rather than a route change.
|
||||
*Question:* Should §3 be corrected to the shipped path `POST /voice/extract`, since no work item
|
||||
moves the route?
|
||||
*Suggested spec change:* Change §3's heading to `POST /voice/extract` and note that
|
||||
`VOICE_EXTRACT_PATH` in `common/body-parsers.ts` must move with it if it ever changes.
|
||||
*Evidence:* backend/src/modules/voice/voice.controller.ts:23,36; backend/src/common/body-parsers.ts:10
|
||||
|
||||
**Note 4 — `contradiction` (dyolink).** §11 item 13 says v1 ships with "no availability endpoint",
|
||||
but §4 requires the frontend to learn enabled locales from the API, and `GET /voice/availability`
|
||||
exists and is what `voiceForEditor` reads today.
|
||||
|
||||
> ~~**Availability API**~~ — **resolved for v1:** voice ships **open to everyone** with a configured locale profile. No plan check, no availability endpoint.
|
||||
|
||||
> The frontend must learn which locales are enabled **from the API**, not from a `NEXT_PUBLIC_*` var
|
||||
|
||||
*Why not blocking:* work item 2 ("`voiceForEditor` also checks `isMediaRecorderSupported()`")
|
||||
presupposes `voiceAvailability` stays, so the working reading is obvious. The stale line just needs
|
||||
correcting so nobody deletes a live endpoint.
|
||||
*Question:* Confirm the availability endpoint stays in v1 and only the plan check is deferred?
|
||||
*Suggested spec change:* Reword item 13 to "no plan check; the availability endpoint ships and
|
||||
reports configured locales and `maxRecordingMs` only."
|
||||
*Evidence:* backend/src/modules/voice/voice.controller.ts:28; frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625
|
||||
|
||||
**Note 5 — `brownfield` (dyolink).** §5's disjointness counts are slightly off: the seed defines 41
|
||||
distinct prosthesis codes (not 42) and 5 subcategory names — veneer, inlay, onlay, overlay,
|
||||
night_guard (not 4). The disjointness claim itself holds: no leaf code equals any category or
|
||||
subcategory name.
|
||||
|
||||
> This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7 category names and 4 subcategory names, no collisions.
|
||||
|
||||
*Why not blocking:* the test asserts disjointness, not counts, and disjointness is true against the
|
||||
current seed. Flagged so the builder writes the test against the live catalog rather than a
|
||||
hardcoded 42/7/4.
|
||||
*Question:* May the disjointness test assert against the live catalog instead of the counts written
|
||||
in §5?
|
||||
*Suggested spec change:* Drop the exact counts and keep only "the leaf, category and subcategory
|
||||
namespaces are disjoint; a test asserts it against the live catalog".
|
||||
*Evidence:* backend/prisma/catalog-seed-data.ts:121-400
|
||||
|
||||
### Files read during the gap-check
|
||||
|
||||
`docs/specs/voice-treatment-entry/spec.md`, `docs/specs/voice-treatment-entry/progress.md`,
|
||||
`backend/src/modules/voice/extraction.prompt.ts`, `backend/src/modules/voice/extraction.wire.ts`,
|
||||
`backend/src/modules/voice/voice.providers.ts`, `backend/src/modules/voice/voice.service.ts:243`,
|
||||
`backend/src/modules/voice/voice.controller.ts:23`,
|
||||
`backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts:72`,
|
||||
`backend/prisma/catalog-seed-data.ts`, `backend/prisma/schema.prisma:331`,
|
||||
`backend/prisma/export-prosthesis-catalog.ts:16`, `backend/src/common/fdi.ts`,
|
||||
`backend/src/common/body-parsers.ts:10`,
|
||||
`backend/src/modules/treatments/lab-case-send.validation.ts:116`,
|
||||
`frontend/src/components/treatment/prosthesisTree.ts`,
|
||||
`frontend/src/components/treatment/voiceReviewRows.ts`, `frontend/src/types/voice.ts`,
|
||||
`frontend/src/lib/voice/audioFormat.ts`, `frontend/src/lib/voice/useVoiceCapture.ts:157`,
|
||||
`frontend/src/components/ui/treatment/TreatmentWorkspace.tsx:625` and `:2184`,
|
||||
`frontend/src/components/ui/treatment/FdiToothChart.tsx:218`,
|
||||
`frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx:200`,
|
||||
`frontend/messages/en.json:1124`, `frontend/package.json`
|
||||
(all paths relative to `/Users/aminmsvi/PersonalProjects/dyolink`)
|
||||
|
||||
### How to resume
|
||||
|
||||
Close the gaps in the spec, then re-run. Pass `gapNote` describing what you changed, or the
|
||||
analyst replays this same verdict from cache.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — Gaps closed, ready to re-run
|
||||
|
||||
All five findings are now answered in the spec. Nothing was built in the halted run, so there is
|
||||
no code to revisit.
|
||||
|
||||
| Finding | Resolution | Spec |
|
||||
|---|---|---|
|
||||
| **Blocking** — no backend source for category labels | Add `PROSTHESIS_CATEGORY` + `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind`, one migration, seed fa/en/nl from the existing `prosthesis.category_*` / `sub_*` keys, resolve via `CatalogLabelService`. Frontend keeps its own keys — migrating it is out of scope | §5, decisions 47–48 |
|
||||
| Note 1 — chip with no assignment link | Unresolved items raised inside an assignment carry `assignmentIndex`; a picked chip inherits that assignment's `types[]` | §6, decision 50 |
|
||||
| Note 2 — mixed-region category | `removable` defers its region check to the picked leaf; never `code_not_valid_for_target` while still a category | §5, decision 49 |
|
||||
| Note 3 — wrong endpoint path in §3 | Corrected to `POST /voice/extract`, with the `body-parsers.ts` coupling called out. No work item moves the route | §3 |
|
||||
| Note 4 — availability endpoint contradiction | §11 item 13 corrected: the endpoint exists, only the plan check is deferred | §11 |
|
||||
| Note 5 — disjointness counts | Verified independently: **42** leaf codes (the surveyor's 41 missed `screw_retained`, a multi-line `implant(` call) and **5** subcategories (the spec said 4, missing `night_guard`). The test now asserts against the live catalog, not a written count | §5, §12 |
|
||||
|
||||
Referee relays: 0. Gate repairs: 0. Neither phase was reached.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — Implementation (dyolink, extraction contract + resolvers + review sheet)
|
||||
|
||||
All 19 work items above are built. Nothing committed or pushed — that is the orchestrator's job.
|
||||
|
||||
### Backend
|
||||
|
||||
- `backend/prisma/schema.prisma` — `PROSTHESIS_CATEGORY`, `PROSTHESIS_SUBCATEGORY` added to
|
||||
`CatalogEntityKind` (additive, no data loss).
|
||||
- `backend/prisma/migrations/20260907120000_prosthesis_category_catalog_kinds/migration.sql` —
|
||||
hand-written `ALTER TYPE ... ADD VALUE` migration, following the exact pattern of the repo's
|
||||
one precedent (`20260718180000_lab_case_activity_task_assigned`). **Not applied** — see
|
||||
"Not run" below.
|
||||
- `backend/prisma/catalog-seed-data.ts` — `PROSTHESIS_CATEGORY_LABELS` (7) and
|
||||
`PROSTHESIS_SUBCATEGORY_LABELS` (5), worded from the frontend's existing `category_*`/`sub_*`
|
||||
message keys (decision 47/48), folded into `CATALOG_TRANSLATIONS`.
|
||||
- `backend/src/common/fdi.ts` — `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` sentinels, mirroring the
|
||||
frontend's `prosthesisTree.ts` convention so both sides speak the same jaw target.
|
||||
- `backend/src/modules/voice/voice.types.ts` — `ProsthesisAssignment` replaces
|
||||
`ProsthesisIntent` (default+overrides retired, decision 35); `UnresolvedReason` gains
|
||||
`prosthesis_type_ambiguous`, `arch_not_spoken`, `code_not_valid_for_target`, loses
|
||||
`tooth_not_selected`; `UnresolvedItem.assignmentIndex` added (decision 50).
|
||||
- `backend/src/modules/voice/extraction.wire.ts` — wire schema carries `prosthesis:
|
||||
WireProsthesisAssignment[]`; `WireToothIntent.arch` gains `'both'`.
|
||||
- `backend/src/modules/voice/extraction.resolver.ts` — rewritten: `resolveAssignmentTarget`
|
||||
(tooth vs. jaw, mirrors the old `tooth_missing_quadrant`/new `arch_not_spoken` split),
|
||||
`classifyTypeCode` (leaf / category / subcategory, disjoint namespaces), and
|
||||
`resolveProsthesisAssignment` composing both plus the region-validity check (deferred for a
|
||||
mixed-region category — `removable` today, decision 49 — computed generically from the
|
||||
catalog's own chart regions rather than hardcoding the category name). `resolveVoiceIntent`
|
||||
forces `treatmentType` to `prosthesis` when any assignment resolves a target (decision 41).
|
||||
`resolveProsthesis`/`ResolvedProsthesis` retired outright.
|
||||
- `backend/src/modules/voice/extraction.prompt.ts` — `prosthesisTree()` renders the catalog as
|
||||
CATEGORY → (SUBCATEGORY →) leaf from the data `buildCatalog` supplies; no catalog knowledge
|
||||
hardcoded in the prompt text itself.
|
||||
- `backend/src/modules/voice/voice.providers.ts`, `voice.service.ts` — `ExtractionCatalog`
|
||||
carries the full leaf shape plus `prosthesisCategories`/`prosthesisSubcategories`;
|
||||
`buildCatalog` no longer strips `category`/`subcategory`/`chartRegion`/`stackGroup`.
|
||||
- `backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts` —
|
||||
`listCategories()`/`listSubcategories()`, resolved through `CatalogLabelService` like every
|
||||
other catalog label.
|
||||
- Jest: `extraction.resolver.spec.ts` rewritten around `resolveProsthesisAssignment` (stacks,
|
||||
jaw targets, both-jaws, leaf/category/subcategory classification, disjointness against the
|
||||
live `PROSTHESIS_TYPES`, region validity both ways, the `removable` deferral, assignment-index
|
||||
attribution); `extraction.wire.spec.ts` and `openrouter.provider.spec.ts` updated for the new
|
||||
wire shape. 209 backend tests pass (121 in `modules/voice`).
|
||||
|
||||
### Frontend
|
||||
|
||||
- `frontend/src/lib/voice/audioFormat.ts` — `pickRecordingMimeType`'s final fallback is now `''`
|
||||
instead of `null` (item 1's Safari fix — modern Safari's `isTypeSupported` can reject every
|
||||
preferred container yet still record when let choose).
|
||||
- `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx` — `voiceForEditor` now also
|
||||
requires `isMediaRecorderSupported()`; `applyVoiceResult` rewritten: teeth vs. prosthesis are
|
||||
mutually exclusive per `isLabDependentResult`, the prosthesis stack is built through
|
||||
`prosthesisTargetLines` (which routes every leaf through `applyLeafToJobs`), and jaw targets
|
||||
write `LabCaseToothProsthesisDraft` rows keyed on the `UA`/`LA` sentinels — no separate
|
||||
arch-specific code path needed beyond what `prosthesisTree.ts` already provides.
|
||||
- `frontend/src/types/voice.ts` — `VoiceProsthesisAssignment[]` replaces the byTooth map;
|
||||
`VoiceUnresolvedItem.assignmentIndex`.
|
||||
- `frontend/src/components/treatment/voiceReviewRows.ts` — rewritten: `isLabDependentResult`,
|
||||
merged-row `voiceRowAvailability`, `withChosenArch`/`withChosenProsthesisLeaf` (decision 50),
|
||||
`prosthesisTargetLines` (previews the stack via `applyLeafToJobs`, names refused jobs),
|
||||
`joblessProsthesisTargets` (decision 40 — named, struck through, never silently dropped or
|
||||
silently applied), `prosthesisChartData` (crown/root/arch tints for the merged row's chart).
|
||||
- `frontend/src/components/ui/treatment/VoiceReviewSheet.tsx` — merged "Teeth and prosthesis"
|
||||
row for a labDependent type; three independent candidate-chip kinds (tooth, jaw, leaf) each
|
||||
folding through their own `voiceReviewRows` helper; `voiceProsthesisIncomplete` warning
|
||||
removed (all-or-nothing retired).
|
||||
- i18n: `en.json`/`fa.json`/`nl.json` — `voiceUnresolved.*` updated for the new/retired reasons,
|
||||
`voiceTeethAndProsthesis`, `voicePickJaw`, `voiceNoProsthesisHeard`, `voiceStackRefused` added,
|
||||
`voiceProsthesisIncomplete` removed.
|
||||
- `frontend/package.json`, `frontend/vitest.config.ts` — `vitest@3.2.7` (pinned to a version
|
||||
whose peer `@types/node` range still includes the repo's `^20`; vitest 4/5 require `>=22`),
|
||||
`npm run test` → `vitest run`, alias-only config (`@` → `src/`).
|
||||
- `frontend/src/components/treatment/prosthesisTree.spec.ts`,
|
||||
`voiceReviewRows.spec.ts` — 37 Vitest cases covering stack legality, `applyLeafToJobs`
|
||||
precedence, `toothRegionColors`, row availability, chip folding, the merged-row preview, and
|
||||
the jobless/pending distinction.
|
||||
- `CLAUDE.md` — Tests section rewritten; frontend command table gains `npx vitest run`.
|
||||
|
||||
### Verification run
|
||||
|
||||
- `cd backend && npm test` — 209/209 pass (121 in `modules/voice`).
|
||||
- `cd backend && npm run build` — clean (after `npm install`, which pulled in `@sentry/nestjs`
|
||||
that `node_modules` was missing — unrelated to this change, pre-existing on this checkout).
|
||||
- `cd backend && npx prisma generate` — succeeds against the updated schema (no DB needed);
|
||||
confirms `CatalogEntityKind.PROSTHESIS_CATEGORY`/`PROSTHESIS_SUBCATEGORY` compile everywhere
|
||||
they're used.
|
||||
- `cd frontend && npx tsc --noEmit` — clean.
|
||||
- `cd frontend && npx vitest run` — 37/37 pass.
|
||||
- `cd frontend && npm run build` — production build succeeds.
|
||||
- ESLint on every touched file — 0 errors, 0 new warnings (pre-existing warnings elsewhere in
|
||||
`TreatmentWorkspace.tsx`, unrelated to this change, left untouched).
|
||||
|
||||
### Not run (environment limitation, not a design gap)
|
||||
|
||||
- `npm run prisma:migrate && npm run prisma:seed` against a live Postgres — this sandbox has no
|
||||
running Docker daemon (`docker info` never came up after several minutes and `open -a Docker`
|
||||
did not launch it), so the migration was never applied to a database and the new
|
||||
`CatalogTranslation` rows were never seeded. The migration SQL and seed data are written and
|
||||
reviewed against the one existing precedent in this repo; **run both before merging**.
|
||||
- The full manual pass in §12 (Safari/iPad recording, live extraction against the real OpenRouter
|
||||
API, the specific stack/jaw/ambiguity scenarios) — needs a browser and a live backend, neither
|
||||
available in this session.
|
||||
|
||||
### Deviations from spec / judgement calls made while implementing
|
||||
|
||||
- **A target with empty `types[]` still resolves as a target**, with `types: []` on its
|
||||
assignment — not excluded from the assignment's `targets` array. The sheet (frontend) treats
|
||||
`types.length === 0` with no matching `prosthesis_type_ambiguous` unresolved item as "jobless,
|
||||
struck through" (decision 40), and the same empty-types-plus-ambiguous-item combination as
|
||||
"pending a material pick" instead. This keeps the wire contract simple (no extra field) at the
|
||||
cost of the frontend doing that one bit of inference from `unresolved` — documented in both
|
||||
`extraction.resolver.ts` and `voiceReviewRows.ts`.
|
||||
- **The mixed-region deferral (decision 49) is computed generically** from each category's
|
||||
actual leaf chart-regions (`regions.size === 1` → validate immediately, else defer) rather than
|
||||
special-cased for `removable` by name. This also defers `implant` (which spans `root` and
|
||||
`crown` via `screw_retained`) — the spec's prose says "only `removable` today" as an
|
||||
observation about the current catalog, not an instruction to hardcode that name, and deferring
|
||||
a category no test forbids deferring is the safer default.
|
||||
- **`voice.dto.ts`/`voice.controller.ts` needed no changes.** The scout's item 6 ("validation for
|
||||
new schema shape") does not apply: the DTO validates the client's audio submission
|
||||
(`audio`/`format`/`timeZone`/`durationMs`/`locale`), which is unrelated to the LLM's structured
|
||||
output shape that changed. Confirmed by reading both files; not a silent skip.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — Re-run reached Ship; stopped, and the challenge phase is incomplete
|
||||
|
||||
Recorded by hand: the `record-stop:Ship` agent failed on the session spend limit before it
|
||||
could write this.
|
||||
|
||||
**Stop reason:** `no MR template found in dyolink — refusing to invent a description.` Expected
|
||||
and flagged at preflight: `.gitea/` holds only `workflows/`, and the remote is Gitea, not GitLab.
|
||||
A `--dry` run pushes nothing regardless.
|
||||
|
||||
| Phase | Result |
|
||||
|---|---|
|
||||
| Gap-check | **clear** — the five gaps closed in `77e2ed4` were accepted |
|
||||
| Scout | 1 of 3 Explore sweeps returned; 2 ended without structured output |
|
||||
| Implement | **done** — all 19 work items, ~3,100 insertions across 25 files + 4 new files |
|
||||
| Gate | **green** — backend 16 suites / 209 tests, `nest build` 0, `prisma validate` ok; frontend Vitest 2 files / 37 tests, `tsc --noEmit` 0, `next build` 0 |
|
||||
| Refute | **incomplete** — `correctness` refuted with 2 findings; `regression-risk` never ran (spend limit) |
|
||||
| Ship | stopped, no template. `clerk` also failed on the spend limit |
|
||||
|
||||
Gate repairs: 0. Referee relays: 0. Nothing committed, nothing pushed.
|
||||
|
||||
### Two confirmed findings — verified by hand, not taken on the critic's word
|
||||
|
||||
1. **`VoiceReviewSheet.tsx:169` — a picked tooth chip is silently dropped.** `pickCandidate`
|
||||
sets `teeth: prev.teeth || available.teeth`, but `available` is memoised from `effective`,
|
||||
which depends on `chosenTeeth`. Both `setChosenTeeth` and `setSelection` run in the same
|
||||
handler, so the updater closes over the pre-pick `available`, where `available.teeth` is
|
||||
`false` because `result.teeth` is empty. `prev.teeth` is false too, so it stays false
|
||||
permanently. The row then renders with the tooth on the chart and the box unticked, and Apply
|
||||
drops it. This is a variant of the original live-test failure — "ترمیم برای دندون دو".
|
||||
2. **`VoiceReviewSheet.tsx:213` + `TreatmentWorkspace.tsx:2215` — decision 41 not implemented.**
|
||||
The treatmentType row is a plain `toggle('treatmentType')` with no lock, and
|
||||
`applyVoiceResult` derives `labDependent` from `result.treatmentType` rather than the
|
||||
`detail.treatmentType` it writes. Untick the type row on a forced-prosthesis recording and a
|
||||
`restoration` detail is saved carrying prosthesis lab rows — the state decision 41 exists to
|
||||
make unreachable.
|
||||
|
||||
### Lint
|
||||
|
||||
Backend touched files: 0 errors, 0 warnings. Frontend touched files: 10 warnings, all
|
||||
pre-existing in `TreatmentWorkspace.tsx`. The repo-wide backend baseline (1,288 errors, 1,107
|
||||
prettier-fixable) is untouched by this diff.
|
||||
|
||||
## Next steps / open questions
|
||||
|
||||
- The diff is **unreviewed on the `regression-risk` lens**. Green gate plus one refuting lens is
|
||||
not the design's bar; the script continued only because the threshold counts refusals and the
|
||||
second critic errored rather than refused.
|
||||
- Fix the two findings, then resume from Refute so both lenses grade the same diff.
|
||||
- The Ship phase cannot pass in this repo until there is an MR template, or until the MR is
|
||||
opened by hand. Opening one is gated regardless.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — Second refute round, both lenses, on the repaired diff
|
||||
|
||||
Ran both critics directly rather than resuming the workflow: a resume would have replayed the
|
||||
`correctness` verdict from cache against a diff that had changed, and re-run the Ship phase that
|
||||
cannot pass in this repo anyway.
|
||||
|
||||
**`correctness` refuted with 4 findings — all confirmed by hand, all fixed in `f1a4594`.**
|
||||
|
||||
Three were the same root cause: the region check never actually ran. Each wrote a job the manual
|
||||
chart cannot produce, and each passed all 209 tests.
|
||||
|
||||
| Finding | Effect |
|
||||
|---|---|
|
||||
| The deferred region check had nothing to complete it | "پروتز متحرک برای دندون ۱۲" wrote a complete denture onto tooth 12 |
|
||||
| `some` over a stack's regions | `['pfm_crown','night_guard_soft']` on tooth 12 wrote a night guard onto that tooth |
|
||||
| `regions.size === 1` also deferred `implant` (root + crown, both tooth regions) | an implant aimed at a jaw resolved as a `UA` target |
|
||||
| The `e271858` lock ticked the row and the payload but not the count | the sheet read "Apply 1 field" while two landed |
|
||||
|
||||
Fix: targets resolve **before** types, so the target kind narrows a category's candidates and an
|
||||
impossible leaf is never offered. The deferral concept is gone. Region validation is now
|
||||
`every`, and a category with no usable leaf drops the assignment's targets rather than leaving
|
||||
them jobless. Four tests added.
|
||||
|
||||
**`regression-risk` refuted with 2 findings.**
|
||||
|
||||
1. Already fixed in `f1a4594` — the spec file's `filter(Boolean)` left two `tsc` errors. It added
|
||||
a fact worth keeping: **`.gitea/workflows/*` run no test, lint or typecheck step at all.**
|
||||
`nest build` excludes `**/*spec.ts` and ts-jest runs transpile-only under
|
||||
`isolatedModules`, so nothing in this repo would ever have caught it. Repo-wide gap, not this
|
||||
branch's.
|
||||
2. **Open — needs a decision, not a fix.** See below.
|
||||
|
||||
### Open question: teeth are unreachable when the merged row is unticked
|
||||
|
||||
On a forced-prosthesis result `voiceRowAvailability` sets `teeth: false`, so no teeth row renders
|
||||
and `selection.teeth` can never become true. Untick the merged prosthesis row (which releases the
|
||||
decision-41 lock) and then untick the type row, and both write paths in `applyVoiceResult` are
|
||||
skipped — the detail is appended with `teeth: []`. `master` kept the teeth here, because the rows
|
||||
were separate.
|
||||
|
||||
This is decision 39 behaving exactly as approved: teeth and prosthesis apply together. But it
|
||||
means a clinician who wants only the teeth from a prosthesis dictation, intending to pick
|
||||
materials by hand, has no way to get them. Not a spec violation — an unstated consequence.
|
||||
Deliberately not changed unilaterally.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — Migration and seed proven against dev Postgres
|
||||
|
||||
The last unverified gate. The inspector had run `prisma validate`, which only parses the schema;
|
||||
the build compiled solely because the generated client in `node_modules` already carried the new
|
||||
enum values.
|
||||
|
||||
Found five migrations pending, not one — this dev database had not been migrated since the
|
||||
prosthesis overhaul landed on `master` on 1 September (`a3c14a1`, `72f885d`). Prisma applies in
|
||||
order, so proving one required applying all five.
|
||||
|
||||
Used **`prisma migrate deploy`, not `migrate dev`**: `deploy` applies pending migrations and never
|
||||
offers to reset, so there is no path where a drift prompt drops dev data. §12 should say `deploy`
|
||||
for this reason.
|
||||
|
||||
Counted the `DELETE` in `…_prosthesis_tree_multi_type` before applying rather than trusting its
|
||||
own "no-op for current data" comment: **0 rows matched**, against 2 rows total in
|
||||
`lab_case_tooth_prosthesis`. Both login accounts survived (`users` = 2).
|
||||
|
||||
Verified after seeding:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `enum_range(CatalogEntityKind)` | now 5 values, including `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` |
|
||||
| Seeded `PROSTHESIS_CATEGORY` rows | 7 per locale × fa/en/nl |
|
||||
| Seeded `PROSTHESIS_SUBCATEGORY` rows | 5 per locale × fa/en/nl |
|
||||
| `crown` in `fa` | `روکشها` — the label that makes "روکش" resolvable to a category |
|
||||
| Code path | `prosthesis-catalog.service.ts:108,119` resolves both kinds via `CatalogLabelService` |
|
||||
|
||||
**Repo-wide gap worth acting on separately:** `.gitea/workflows/*` run no test, lint or typecheck
|
||||
step — only build and deploy. Combined with `nest build` excluding `**/*spec.ts` and ts-jest
|
||||
running transpile-only under `isolatedModules`, nothing in this repo would ever catch a type error
|
||||
in a spec file. That is how two of them reached this branch unnoticed.
|
||||
|
||||
## Remaining before this is shippable
|
||||
|
||||
- The **manual pass in §12** — none of it has been run. Safari and iPad especially, since that is
|
||||
the report that started the revision, and the stack / jaw / chip flows in `fa`.
|
||||
- The Ship phase cannot pass in this repo: no MR template exists, and opening a merge request is
|
||||
gated regardless.
|
||||
@@ -1,14 +1,33 @@
|
||||
---
|
||||
type: task
|
||||
status: active
|
||||
created: 2026-08-20
|
||||
repos:
|
||||
- repo: dyolink
|
||||
path: ~/PersonalProjects/dyolink
|
||||
branch: feat/treatment/add-voice-input-for-new-treatment-form
|
||||
base: origin/master
|
||||
role: extraction contract + resolvers + review sheet
|
||||
spec_slug: voice-treatment-entry
|
||||
merge_after: []
|
||||
aliases: [voice treatment entry, voice input, dictation, mic]
|
||||
---
|
||||
|
||||
# Voice treatment entry
|
||||
|
||||
**Status:** Implemented on `feat/voice-treatment-entry`, with one specified piece missing —
|
||||
the transcript-salvage dialog (§9). First live test on 2026-08-21 sent the tooth path back
|
||||
for revision — a spoken number is now read as its FDI code (§6).
|
||||
**Status:** v1 is merged to `master` (`c07f550`…`dc10d8d`). The treatment form was then
|
||||
overhauled on top of it (`a3c14a1`, `7f92e73`, `72f885d`, `5d3597f`): one prosthesis type
|
||||
per tooth became **stacked jobs**, **jaw-level appliances** and a **category tree**. Voice
|
||||
still compiles against that model but can no longer express it, and in two places now
|
||||
writes data the form itself refuses. This revision adapts the extraction contract (§5),
|
||||
the resolvers (§6) and the review sheet (§7), and carries two recording defects found on
|
||||
Safari (§2, §9).
|
||||
Still blocked on the ASR spike (§11 item 1) before it is trustworthy in front of patients
|
||||
**Area:** Treatment workspace (CLINIC orgs)
|
||||
**Created:** 2026-08-20
|
||||
**Created:** 2026-08-20 · **Revised:** 2026-09-07
|
||||
|
||||
Fill a `TreatmentDetail` — including its lab dispatch — by speaking, instead of by
|
||||
tapping through the type dropdown, the FDI chart, the prosthesis wizard and the lab
|
||||
tapping through the type dropdown, the FDI chart, the prosthesis job popover and the lab
|
||||
picker.
|
||||
|
||||
---
|
||||
@@ -30,7 +49,7 @@ One recording produces **exactly one** `TreatmentDetail`, and may fill every fie
|
||||
| `teeth` | FDI codes, via tooth-intent resolver |
|
||||
| `toothSelectionGroups` | connected (bridge) / single spans |
|
||||
| `comment` | cleaned dictated notes |
|
||||
| lab: `prosthesisTypeCode` per tooth | default type + per-tooth overrides |
|
||||
| lab: `LabCaseToothProsthesis` rows | one assignment list — a target is a tooth **or a jaw**, and carries a **stack** of job codes |
|
||||
| lab: `destinationOrganizationId` | matched against the clinic's linked labs |
|
||||
| lab: `dueDate` | via due-date intent resolver |
|
||||
|
||||
@@ -180,6 +199,12 @@ Three different reasons for "no", rendered differently:
|
||||
"This feature isn't yours" and "not right now" are different statements. Absence avoids a
|
||||
permanently dead control; disabling avoids the button resizing as the day strip moves.
|
||||
|
||||
**The technical row was specified and never implemented.** `voiceForEditor` gated the mic on
|
||||
the server's availability response alone — `enabled && locales.includes(locale)` — and never
|
||||
called `isMediaRecorderSupported()`. So the control rendered on a browser that cannot record
|
||||
and failed on tap, which is how the Safari report arrived. It now tests both. That is what
|
||||
"segment absent" meant all along.
|
||||
|
||||
`TAB_TREATMENT_EDIT` is resolved via `common/membership-permissions.ts`, never by reading
|
||||
`membership.permissions` directly.
|
||||
|
||||
@@ -247,7 +272,14 @@ mirroring and Jalali conversion — testable instead of hopeful.
|
||||
|
||||
### Endpoint
|
||||
|
||||
`POST /treatments/voice-extract`
|
||||
`POST /voice/extract` — `@Controller('voice')` + `@Post('extract')`, so the full path behind the
|
||||
global prefix is `/api/voice/extract`. Earlier revisions of this spec named it
|
||||
`/treatments/voice-extract`, which was never the shipped route. **No work item moves it.**
|
||||
|
||||
> ⚠ The path is duplicated in `common/body-parsers.ts`, which matches `/api/voice/extract` to
|
||||
> select the large JSON body limit. Moving the route without moving `VOICE_EXTRACT_PATH` makes
|
||||
> every real recording 413 against the 100 kb default, which reads as a broken microphone rather
|
||||
> than a routing change.
|
||||
|
||||
- Guards: `JwtAuthGuard` + `ClinicOrgGuard`.
|
||||
- Service-level check of `TAB_TREATMENT_EDIT`. The plan flag is **not** checked in v1
|
||||
@@ -379,19 +411,24 @@ type VoiceIntent = {
|
||||
treatmentType: string | null; // catalog code, from the supplied closed list
|
||||
teeth: ToothIntent[];
|
||||
connectedSpans: { from: ToothIntent; to: ToothIntent }[];
|
||||
comment: string | null;
|
||||
prosthesis: {
|
||||
defaultType: string | null; // catalog code
|
||||
overrides: { tooth: ToothIntent; type: string }[];
|
||||
} | null;
|
||||
comment: string | null; // only when asked for — see below
|
||||
commentTrigger: string | null; // the words that asked; null discards the comment
|
||||
prosthesis: ProsthesisAssignment[]; // empty array, never null
|
||||
labId: string | null; // must be one of the supplied linked-lab ids
|
||||
labMatchExact: boolean;
|
||||
due: DueIntent | null;
|
||||
};
|
||||
|
||||
/** One spoken instruction: these targets get these jobs. */
|
||||
type ProsthesisAssignment = {
|
||||
targets: ToothIntent[]; // each one a tooth, or a jaw — see below
|
||||
types: string[]; // leaf codes, or one category / subcategory code
|
||||
spoken: string; // the transcript span, echoed back to the clinician
|
||||
};
|
||||
|
||||
type ToothIntent =
|
||||
| { kind: 'explicit'; fdi: string; spoken: string }
|
||||
| { kind: 'positional'; arch: 'upper' | 'lower';
|
||||
| { kind: 'positional'; arch: 'upper' | 'lower' | 'both';
|
||||
side: 'patient_right' | 'patient_left'; position: number; spoken: string };
|
||||
|
||||
type DueIntent =
|
||||
@@ -401,12 +438,126 @@ type DueIntent =
|
||||
| { kind: 'gregorian'; y: number; m: number; d: number };
|
||||
```
|
||||
|
||||
The wire form stays deliberately flat (`extraction.wire.ts`): strict `json_schema` mode has
|
||||
poor support for discriminated unions, so every variant field is present and nullable and
|
||||
`toVoiceIntent` narrows it.
|
||||
|
||||
### One assignment list, no default
|
||||
|
||||
`prosthesisDefaultType` and `prosthesisOverrides` are gone. A default that fills every tooth
|
||||
and is then overwritten per tooth has a precedence rule, and a precedence rule has a wrong
|
||||
side — the first live prosthesis test previewed PFZ on tooth 13 and filled PFM. A single list
|
||||
where every entry names its own targets has no precedence to get wrong.
|
||||
|
||||
Targets are `ToothIntent`s; the examples below write them as bare FDI codes for brevity.
|
||||
|
||||
- `{ targets: [12, 13], types: ['pfm_crown'] }` — two teeth, one job. Everything the default
|
||||
expressed, without the default.
|
||||
- `{ targets: [12], types: ['zirconia_abutment', 'monolithic_zirconia'] }` — one tooth, a
|
||||
**stack**. This is what the old shape could not say at all.
|
||||
|
||||
### A target is a tooth or a jaw, and the code decides which
|
||||
|
||||
Nothing on the wire declares "this is a jaw". A target that names an arch and no position is
|
||||
a jaw, and the resolver confirms that against the `chartRegion` of the assignment's codes.
|
||||
`arch` gains `'both'`, which the old enum could not express.
|
||||
|
||||
Deriving it costs no second source of truth. The catalog already answers the question, and
|
||||
`partial_denture` — a `removable` code that is nonetheless assigned per tooth — already ships
|
||||
with `chartRegion: 'crown'` in `catalog-seed-data.ts`, so the exception needs no special case
|
||||
either.
|
||||
|
||||
A code whose region contradicts its target is reported, never coerced. An arch appliance
|
||||
aimed at tooth 12, or a crown aimed at the upper jaw, resolves to
|
||||
`code_not_valid_for_target` (§6).
|
||||
|
||||
**A category whose leaves span both regions defers the check.** `removable` is the only one
|
||||
today: `complete_denture` and `overdenture` are `chartRegion: 'arch'` while `partial_denture` is
|
||||
`'crown'`. There is no region to check until a leaf is chosen, so such an assignment resolves to
|
||||
`prosthesis_type_ambiguous` and the region is confirmed against the leaf the clinician picks —
|
||||
never emitted as `code_not_valid_for_target` while it is still a category.
|
||||
|
||||
### `types[]` may hold a leaf or a category
|
||||
|
||||
"روکش" is *crown* — a category with nine leaves, not a material. The model returns the
|
||||
category code it actually heard rather than guessing `pfm_crown`, and the review sheet offers
|
||||
the leaves as chips (§7).
|
||||
|
||||
This needs no extra field because the namespaces are disjoint: **42** leaf codes against **7**
|
||||
category names and **5** subcategory names (`veneer`, `inlay`, `onlay`, `overlay`,
|
||||
`night_guard`), no collisions. The resolver classifies by lookup. The test asserts disjointness
|
||||
against the **live catalog**, not against these counts, because a future entry named `crown`
|
||||
would quietly turn a leaf into an ambiguity and a hardcoded count would not notice.
|
||||
|
||||
#### Category labels need a backend source — build it
|
||||
|
||||
The prompt cannot offer a category the model can name unless that category has a label in the
|
||||
actor's locale, and today it has none. `CatalogEntityKind` covers only `TREATMENT_TYPE`,
|
||||
`PROSTHESIS_TYPE` and `LAB_WORKFLOW_STEP` (`schema.prisma`), so `CatalogLabelService` cannot
|
||||
resolve a category at all. The only category labels in the repo are the `prosthesis.category_*`
|
||||
and `prosthesis.sub_*` keys in `frontend/messages/*.json`, plus a hardcoded single-locale map in
|
||||
the dev export script. Sending bare codes would leave `crown` and `post_core` untranslated
|
||||
beside fully-labelled leaves, on the locale this feature exists for.
|
||||
|
||||
So this branch adds them as first-class catalog entities:
|
||||
|
||||
- add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind` — one
|
||||
migration, no data loss, the enum is additive;
|
||||
- seed `CatalogTranslation` rows for the 7 categories and the 5 subcategories in `fa`, `en` and
|
||||
`nl`, taking the wording from the existing `prosthesis.category_*` / `prosthesis.sub_*` keys
|
||||
so the two surfaces read identically on day one;
|
||||
- resolve them through `CatalogLabelService` like every other catalog label. No label is
|
||||
hardcoded in backend code, and the house rule holds unchanged.
|
||||
|
||||
The `sub_*` key set is wider than the subcategory set — it also carries technique names
|
||||
(`sub_full_contour`, `sub_layered`) and two leaf codes. Seed only the five real subcategories.
|
||||
|
||||
> **Accepted consequence:** the frontend keeps its own `category_*` / `sub_*` message keys, so
|
||||
> the wording now lives in two places. Migrating the frontend to read these labels from the API
|
||||
> is **out of scope for this branch** — it would touch the prosthesis picker, the case sheet and
|
||||
> the print layout, none of which this task otherwise opens. The seed is written from the message
|
||||
> files precisely so the two agree at the point they diverge.
|
||||
|
||||
### A note is written only when the clinician asks for one
|
||||
|
||||
The model does not decide that something was a note. It reports the exact words that asked —
|
||||
«بنویس که», "write this in the notes" — in `commentTrigger`, and `comment` holds what was
|
||||
dictated after them, without the asking words. The resolver keeps `comment` only when a trigger
|
||||
is present, so the rule is enforced by code rather than trusted to the prompt.
|
||||
|
||||
The instruction this replaces said "clinical notes, in the language spoken. Omit the parts
|
||||
already captured as treatment type, teeth, prosthesis work or deadline" — which told the model to
|
||||
sweep up whatever was left over. Filler, small talk and an unrequested diagnosis all became a
|
||||
persisted clinical note, and `comment` is the one durable trace of a recording (§10).
|
||||
|
||||
Speech that fits no field and was not asked to be a note is simply not reported. The sheet shows
|
||||
what was captured, so nothing is hidden by leaving it out.
|
||||
|
||||
Per-locale trigger vocabulary lives in the prompt's locale notes, exactly like the tooth
|
||||
vocabulary. The resolver only checks that a trigger was reported, so it needs no per-locale
|
||||
knowledge and stays locale-neutral by construction (§6).
|
||||
|
||||
### Prosthesis work implies the treatment type
|
||||
|
||||
`prosthesis` is the only `labDependent` treatment type. Any resolved assignment therefore
|
||||
forces `treatmentType` to `prosthesis`, and the sheet locks that row while the prosthesis row
|
||||
is ticked. Without it, a recording that names an appliance but no treatment type seeds the
|
||||
type from the appointment purpose, `prosthesisAssignActive` stays false, the plain chart
|
||||
renders, and the lab rows are orphaned behind a chart that cannot show them.
|
||||
|
||||
### Closed lists
|
||||
|
||||
Every code-valued field is constrained to a **closed list supplied in the prompt**:
|
||||
|
||||
- Treatment types and prosthesis types come from `CatalogLabelService` in the actor's
|
||||
locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of
|
||||
`prosthesis` and `monolithic_zirconia` rather than being asked to translate. Catalog
|
||||
entities store a stable `code` and no label — never hardcode a label.
|
||||
- **The prosthesis list is no longer flat.** `buildCatalog` already receives `category`,
|
||||
`subcategory`, `chartRegion` and `stackGroup` from `ProsthesisCatalogService` and throws
|
||||
all four away. It now passes them through, so the prompt presents the catalog as the tree
|
||||
it is and marks which codes are jaw-level. Still no catalog knowledge hardcoded in the
|
||||
prompt — the shape comes from the data.
|
||||
- Lab candidates are the clinic's linked labs only (`OrganizationLink`), passed as
|
||||
`{ id, name }`. The model may return one of those ids or `null`, nothing else.
|
||||
|
||||
@@ -416,9 +567,11 @@ Every unresolved or rejected item is reported, never silently dropped.
|
||||
|
||||
## 6. Resolvers
|
||||
|
||||
Both live in `backend/src/`, pure and Jest-covered. The frontend has **no test runner**
|
||||
(no jest/vitest, zero spec files) — putting them there would forfeit the testability
|
||||
that justified this whole design.
|
||||
Both live in `backend/src/`, pure and Jest-covered. That placement was originally forced —
|
||||
the frontend had no test runner at all. This revision adds **Vitest** for the frontend's pure
|
||||
helpers (§12), so the split is now a judgement rather than a constraint: intent resolution
|
||||
stays on the backend because it is the trust boundary, and the stack rules stay on the
|
||||
frontend because that is where the form's own rules already live.
|
||||
|
||||
### `resolveToothIntent()`
|
||||
|
||||
@@ -466,16 +619,60 @@ that justified this whole design.
|
||||
|
||||
- Connected spans validate through the shipped helpers — `areArchNeighbors`, `sameArch`,
|
||||
`teethBetweenInclusive`. **Never a 1-tooth connected group.** Anything invalid degrades
|
||||
to singles and is flagged on the review sheet.
|
||||
- Prosthesis: expand `defaultType` across all teeth, then apply per-tooth overrides.
|
||||
- **All-or-nothing.** `assertCompleteToothProsthesisMap` requires every tooth on a
|
||||
`prosthesis` detail to carry a `prosthesisTypeCode` or the send throws
|
||||
`TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE`. So if even one tooth ends untyped, the
|
||||
prosthesis row is marked incomplete and stays unticked — the unshippable state surfaces
|
||||
at review, where it is cheap, not at dispatch minutes later on another screen.
|
||||
to singles and is flagged on the review sheet. Spans stay their own array rather than
|
||||
folding into assignments: linking is offered on the plain chart too (the non-prosthesis
|
||||
`onToggleLink` branch in `TreatmentWorkspace.tsx`), so it is not prosthesis-only.
|
||||
- **An assignment target is a selection.** On a `prosthesis` detail a tooth exists only by
|
||||
carrying a job: `applyCodes` in `ProsthesisAssignChart` returns early on an empty code
|
||||
list, and `pruneDetailTeethToJobs` strips the rest before every save. So
|
||||
`tooth_not_selected` is unreachable for prosthesis work and is retired.
|
||||
- **A tooth with no job is named and left out.** "۱۲ و ۱۳، روکش برای ۱۲" adds only 12; 13
|
||||
appears struck through in the sheet reading *no prosthesis heard*. Keeping it would
|
||||
manufacture a state the manual chart cannot produce and `persistDraft` would delete on the
|
||||
way out.
|
||||
- **Stack legality is not a resolver concern.** `canStackLeaf`, `categoryDisabledForJobs` and
|
||||
the screw-retained exclusion live in `frontend/src/components/treatment/prosthesisTree.ts`,
|
||||
and the backend's own dispatch check knows nothing about stacking. Porting them would make
|
||||
the voice path stricter than the manual path and create two copies of a rule that must never
|
||||
disagree. The sheet and the apply path both route through `applyLeafToJobs` instead (§7).
|
||||
The backend checks only that a code exists and that its region suits its target.
|
||||
- **All-or-nothing is retired.** `assertCompleteToothProsthesisMap` still requires every
|
||||
*remaining* tooth on a prosthesis detail to carry a code, but the new form guarantees that by
|
||||
pruning rather than by refusing, and it now also accepts a detail with no teeth and an arch
|
||||
job. So an incomplete map no longer blocks a tick; the field survives only to name the
|
||||
targets that will be dropped.
|
||||
- **Apply order is teeth → groups → prosthesis**, so `pruneToothProsthesisForGroups`
|
||||
behaves.
|
||||
|
||||
### Unresolved reasons
|
||||
|
||||
Every one is rendered with what was heard, so the clinician sees what the system did not
|
||||
understand. Three of them carry candidates and become chips (§7).
|
||||
|
||||
| Reason | Meaning | Carries |
|
||||
|---|---|---|
|
||||
| `not_permanent_tooth` | deciduous, or outside the permanent set | — |
|
||||
| `position_out_of_range` | a position outside 1–8 | — |
|
||||
| `tooth_missing_quadrant` | a lone digit — four teeth match, fewer when an arch or side was also heard | `candidates`: FDI codes |
|
||||
| `prosthesis_type_ambiguous` | a category or subcategory was heard, not a material | `candidates`: leaf codes |
|
||||
| `arch_not_spoken` | a jaw-level appliance with no jaw | `candidates`: `upper`, `lower` |
|
||||
| `code_not_valid_for_target` | an arch code aimed at a tooth, or a tooth code aimed at a jaw | — |
|
||||
| `unknown_catalog_code` | a code the supplied catalog does not contain | — |
|
||||
| `span_not_same_arch` | a connected span crossing arches | — |
|
||||
| `malformed` | neither an FDI code nor a usable description | — |
|
||||
| `invalid_date` | a date the resolver cannot build | — |
|
||||
|
||||
`tooth_not_selected` is removed — assignments now define their own teeth.
|
||||
|
||||
**An unresolved item that came from an assignment carries that assignment's index.**
|
||||
Without it a chip is decorative: picking a quadrant for "دندون دو" on a prosthesis detail
|
||||
produces a tooth with no job, which the *jobless tooth is left out* rule above then discards, so
|
||||
the tap changes nothing. `tooth_missing_quadrant`, `arch_not_spoken` and
|
||||
`prosthesis_type_ambiguous` therefore carry `assignmentIndex`, and a picked chip inherits that
|
||||
assignment's `types[]` — or, for `prosthesis_type_ambiguous`, supplies the missing leaf to that
|
||||
assignment's existing targets. An item raised outside any assignment (a tooth spoken in the
|
||||
`teeth` array alone) carries no index and folds into `teeth` as it does today.
|
||||
|
||||
---
|
||||
|
||||
## 7. Review sheet
|
||||
@@ -487,34 +684,89 @@ that justified this whole design.
|
||||
> same constraint the realtime soft-refresh already lives under: never remount the
|
||||
> treatment form, never clear a draft.
|
||||
|
||||
- Renders the transcript, then one row per extracted field in the app's own vocabulary:
|
||||
translated catalog labels, and a mini FDI chart for the teeth rather than a list of
|
||||
numbers.
|
||||
- Renders one row per extracted field in the app's own vocabulary: translated catalog labels,
|
||||
and a mini FDI chart for the teeth rather than a list of numbers. **The transcript is not
|
||||
shown** — it never reaches the browser at all (§10).
|
||||
- Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also
|
||||
what creates the new detail — see §2.
|
||||
- Rows default to ticked **except**:
|
||||
- the lab row when `labMatchExact` is false — shipping to a lab always requires a
|
||||
deliberate tick;
|
||||
- any row carrying an unresolved item or an incomplete prosthesis map.
|
||||
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
|
||||
clinician can see what the system did not understand.
|
||||
- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Any
|
||||
per-detail convenience that would top the case up afterwards has to be suppressed for a
|
||||
voice-created case, because a default that quietly adds a prosthesis type to a tooth the
|
||||
sheet never mentioned turns the confirmation step into a lie about what it was going to
|
||||
do — which is the whole reason the step exists.
|
||||
- Rows default to ticked **except** the lab row when `labMatchExact` is false — shipping to a
|
||||
lab always requires a deliberate tick.
|
||||
|
||||
> This branch carried an exemption for one such default, the dispatch panel's
|
||||
> remembered-prosthesis auto-fill. `origin/master` deleted that feature outright
|
||||
> (`f52ad6b`), so the exemption went with it in the rebase and nothing enforces this rule
|
||||
> in code today. It is a constraint on whatever gets added next, not a description of
|
||||
> something that exists.
|
||||
- An item that carries `candidates` renders them as **tappable chips** — the one place the
|
||||
sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and
|
||||
ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a
|
||||
dead end. Everything the sheet renders comes from that folded result, not the raw one.
|
||||
- RTL-safe: logical `text-start` / `text-end` only, never `text-left`/`text-right`.
|
||||
Dates via `lib/i18n/format.ts`.
|
||||
### Teeth and prosthesis are one row
|
||||
|
||||
For a lab-dependent type they are not independent, and two checkboxes let the clinician save a
|
||||
state the form immediately undoes. On `master` today, ticking **teeth** and unticking
|
||||
**prosthesis** on a prosthesis detail saves an **empty detail**: `persistDraft` prunes
|
||||
lab-dependent details to their jobs and `applyVoiceResult` persists straight after applying.
|
||||
|
||||
So a lab-dependent type gets one **Teeth and prosthesis** row that applies together. Every
|
||||
other type keeps a plain teeth row and has no prosthesis row at all. The merged row is:
|
||||
|
||||
- the existing read-only mini `FdiToothChart`, now fed `crownColors` / `rootColors` from
|
||||
`toothRegionColors` and `archHighlight` from the arch jobs. The chart already accepts all
|
||||
three, so showing stacks and jaw work costs no new component;
|
||||
- a line underneath naming each target and what lands on it —
|
||||
`۱۲: ایمپلنت + روکش زیرکونیا · ۱۳: روکش پیافام · فک بالا: نایت گارد`.
|
||||
|
||||
The chart catches a misheard tooth number at a glance; the list confirms the material. Either
|
||||
one alone is the weaker check.
|
||||
|
||||
> **Accepted consequence:** on a prosthesis dictation the teeth are reachable only through this
|
||||
> row. `voiceRowAvailability` sets `teeth: false` for a lab-dependent result, so no plain teeth
|
||||
> row exists and `selection.teeth` never becomes true. Untick the merged row — which releases the
|
||||
> decision-41 lock — then untick the type row, and the detail applies with no teeth at all.
|
||||
> `master` kept them, because the rows were separate.
|
||||
>
|
||||
> This is decision 39 doing what it was chosen for, and it follows from *the sheet is a contract*
|
||||
> below: the row that displayed those teeth was unticked, so applying them anyway would apply
|
||||
> something the clinician refused. The cost is real and accepted — a clinician who wants only the
|
||||
> teeth from a prosthesis dictation, intending to pick materials by hand, has to tap them in. If
|
||||
> that turns out to matter in use, the fix is a spec change, not a patch.
|
||||
|
||||
### The sheet previews the stack that will actually land
|
||||
|
||||
The merged row builds its preview through `applyLeafToJobs` — the same function the manual
|
||||
chart writes through — and `applyVoiceResult` applies through it too. A job the stack rules
|
||||
refuse, such as an implant plus a post & core on one tooth, is shown struck through and named.
|
||||
Not silently dropped, and not silently applied. The rules stay in one file (§6).
|
||||
|
||||
### Three chip kinds, one pattern
|
||||
|
||||
An item carrying `candidates` renders them as **tappable chips** — the one place the sheet is
|
||||
interactive. Picking one folds the choice into the result, so an under-specified item is one
|
||||
tap from resolved instead of a dead end. Everything the sheet renders comes from that folded
|
||||
result, not the raw one.
|
||||
|
||||
| Heard | Missing | Chips |
|
||||
|---|---|---|
|
||||
| "دندون دو" | the quadrant | the four FDI candidates, narrowed by any arch or side also heard |
|
||||
| "روکش" | the material | the leaves of that category or subcategory |
|
||||
| "نایت گارد" | the jaw | Upper / Lower, multi-pick — picking both is how a both-jaw appliance is expressed, since the manual chart has no *both* control either |
|
||||
|
||||
Nothing is guessed on the clinician's behalf. A per-category default material would have the
|
||||
same shape as the auto-fill this design already rejects.
|
||||
|
||||
### The sheet is a contract
|
||||
|
||||
**Confirm fills exactly what the sheet previewed — no more.** Any per-detail convenience that
|
||||
would top the case up afterwards has to be suppressed for a voice-created case, because a
|
||||
default that quietly adds a prosthesis type to a tooth the sheet never mentioned turns the
|
||||
confirmation step into a lie about what it was going to do — which is the whole reason the
|
||||
step exists.
|
||||
|
||||
> The v1 branch carried an exemption for one such default, the dispatch panel's
|
||||
> remembered-prosthesis auto-fill. `master` deleted that feature outright (`f52ad6b`), so the
|
||||
> exemption went with it and nothing enforces this rule in code today. It is a constraint on
|
||||
> whatever gets added next, not a description of something that exists.
|
||||
|
||||
This is also why a missing material becomes chips rather than a per-category default, and why
|
||||
a jobless tooth is named rather than quietly filled.
|
||||
|
||||
### RTL
|
||||
|
||||
Logical `text-start` / `text-end` only, never `text-left` / `text-right`. Dates via
|
||||
`lib/i18n/format.ts`. The merged row's per-target line reads right to left in `fa`, so the
|
||||
separator is a bare `·` with no direction of its own.
|
||||
|
||||
---
|
||||
|
||||
@@ -581,26 +833,25 @@ that throws after permission was already granted. All three are "this browser ca
|
||||
and now report `VOICE_UNSUPPORTED_FORMAT`; blaming the microphone sends the clinician hunting
|
||||
in site settings for a permission nothing ever asked for.
|
||||
|
||||
**Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED`
|
||||
carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was
|
||||
never written — `onError` only resolves a message through `getUserFacingError`, which never
|
||||
reads `details`, so the transcript is shipped in an error body and dropped. Either build the
|
||||
dialog below or stop returning the transcript; shipping dictation to the client and
|
||||
discarding it is the worst of both.
|
||||
**The container list is a preference, not a requirement.** `pickRecordingMimeType` returned
|
||||
`null` when `MediaRecorder.isTypeSupported` rejected all six candidates — five of which are
|
||||
WebM or OGG, which Safari cannot record. So Safari was refused outright, even though it
|
||||
records `audio/mp4`, `mimeTypeToFormat` already maps that to `m4a`, and the backend accepts
|
||||
`m4a`. It now falls back to the empty hint, which is the *let the browser choose* path the
|
||||
function already had for Safari versions that shipped no `isTypeSupported`; `onstop` derives
|
||||
the real container from `recorder.mimeType`, as it already did. `VOICE_UNSUPPORTED_FORMAT` is
|
||||
left for a browser that genuinely cannot record.
|
||||
|
||||
When ASR succeeded and only extraction failed, the response still
|
||||
carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action
|
||||
**creates a new detail with only `comment` set to the transcript** — everything else left
|
||||
at `newDetail()` defaults. The words were captured and paid for; only the structure was
|
||||
lost.
|
||||
**Transcript salvage — dropped, not deferred.** `VOICE_EXTRACT_FAILED` used to carry
|
||||
`details.transcript` so a failure dialog could offer the words back as a note. It was never
|
||||
built, and the transcript no longer reaches the client at all (§10), so the dialog as specified
|
||||
cannot be built either. `toAppException` now returns a code and no `details`.
|
||||
|
||||
This keeps the feature's one invariant intact: **voice never writes into an existing
|
||||
detail.** Dictating into an already-filled detail is a separate, later feature with its
|
||||
own voice-to-text control scoped to that field (§1, out of scope).
|
||||
The trade, stated plainly: when ASR succeeded and only extraction failed, the words were
|
||||
captured and paid for, and the clinician cannot be offered them. They are in the server log,
|
||||
readable by an operator, not by the person who spoke them. The clinician re-dictates.
|
||||
|
||||
It also does not bypass the confirmation rule — the dialog shows the transcript, and the
|
||||
dentist taps to accept it. That review matters, because a raw transcript carries ASR
|
||||
errors and may contain the patient's spoken name, and `comment` is persisted (§10).
|
||||
Reversing this needs a decision about the transcript leaving the server, not just client code.
|
||||
|
||||
---
|
||||
|
||||
@@ -609,7 +860,20 @@ errors and may contain the patient's spoken name, and `comment` is persisted (§
|
||||
- Audio is held **in memory for the request only**. Never written to disk, never a Prisma
|
||||
row. Note this is deliberately unlike treatment attachments, which do persist to
|
||||
`backend/uploads/treatments`.
|
||||
- The transcript goes to the browser for the review sheet and dies with it.
|
||||
- **The transcript never leaves the server.** It is not in the success response and not in any
|
||||
error body. A raw dictation can carry the patient's spoken name, and the review sheet has no
|
||||
need of it — the resolved rows are what the clinician confirms.
|
||||
- The transcript **is** written to the server log, once per recording, at **info** level
|
||||
(`voice.service.ts`, immediately after the emptiness check so an extraction failure still
|
||||
records it). This is a deliberate exception to the rule below, and the only durable trace
|
||||
besides `comment`. It is logged before extraction, not inside `logTelemetry`, so that
|
||||
method's patient-free guarantee stays literally true.
|
||||
|
||||
> ⚠ Consequence to accept: patient words persist in production server logs at default level.
|
||||
> Whatever retention and access control applies to those logs now applies to dictation. The
|
||||
> repo's other sensitive-text path — a vendor error body that can echo the request back —
|
||||
> uses `debug` level and truncates to 500 characters
|
||||
> (`openrouter.provider.ts`). Moving this line to `debug` is a one-word change.
|
||||
- The `comment` field persists a cleaned version of what was said — that is legitimate
|
||||
clinical record-keeping and is the only durable trace.
|
||||
- Telemetry is **structured and patient-free**: clip duration, which fields resolved,
|
||||
@@ -699,8 +963,13 @@ enabling this for real clinics.
|
||||
(§2). Still worth timing a realistic worst-case prosthesis dictation during item 1 to
|
||||
confirm 2 minutes is comfortable rather than tight.
|
||||
13. ~~**Availability API**~~ — **resolved for v1:** voice ships **open to everyone** with a
|
||||
configured locale profile. No plan check, no availability endpoint. The `Plan.features`
|
||||
design in §8 is deferred, not dropped.
|
||||
configured locale profile. **No plan check.** The `Plan.features` design in §8 is deferred,
|
||||
not dropped.
|
||||
|
||||
*Corrected 2026-09-07:* this item previously read "no availability endpoint", which
|
||||
contradicted §4 and the shipped code. `GET /voice/availability` **does** exist and is what
|
||||
`voiceForEditor` reads; it reports the configured locales and `maxRecordingMs`, and carries
|
||||
no plan check. Only the plan gate is deferred, not the endpoint.
|
||||
|
||||
### New
|
||||
|
||||
@@ -715,39 +984,99 @@ enabling this for real clinics.
|
||||
`Plan.features` gate, lower `maxMs` or the throttle, or add an org-level monthly minute
|
||||
budget.
|
||||
|
||||
15. **The voice body limit is raised before any guard runs.** `isVoiceExtractPath` in
|
||||
`common/body-parsers.ts` selects the large JSON limit by path, and Express body parsers
|
||||
run ahead of `JwtAuthGuard`. So an unauthenticated request to that path may upload the
|
||||
full clip-sized body before anything rejects it. The throttle does not help: it is a guard
|
||||
too. Bounded by the DTO cap and by the reverse proxy's own limit, but it is a
|
||||
pre-authentication allocation and nobody has decided whether that is acceptable. Not
|
||||
changed in this revision.
|
||||
|
||||
16. ~~**Transcript salvage is still specified and not built.**~~ — **resolved 2026-09-10:** of
|
||||
its two options, "stop returning the transcript" was taken. The transcript is no longer in
|
||||
the success response or in `details`, the review sheet no longer shows it, and it is logged
|
||||
on the server instead (§9, §10). The `ApiError['details']` shape mismatch goes away with it.
|
||||
|
||||
---
|
||||
|
||||
## 12. Verification
|
||||
|
||||
- `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all
|
||||
four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate`
|
||||
(per-locale week start, "this" vs "next" weekday, Jalali leap year, month-end), the
|
||||
Jalali port, prosthesis expansion + completeness, and connected-span validation.
|
||||
### Machine gates
|
||||
|
||||
- `cd backend && npm test` — the existing suites, plus new coverage for the revised contract:
|
||||
- an assignment resolves its targets, and a target that names an arch with no position
|
||||
resolves to the `UA` / `LA` sentinels;
|
||||
- a leaf code, a category code and a subcategory code are each classified correctly, and
|
||||
the three namespaces are asserted disjoint;
|
||||
- an arch code aimed at a tooth, and a tooth code aimed at a jaw, both resolve to
|
||||
`code_not_valid_for_target`;
|
||||
- a resolved assignment forces `treatmentType` to `prosthesis`;
|
||||
- a target with no types is reported and excluded, and does not fail the whole assignment;
|
||||
- a `removable` category defers its region check instead of emitting
|
||||
`code_not_valid_for_target`;
|
||||
- every one of the 7 categories and 5 subcategories resolves a non-empty label in `fa`, `en`
|
||||
and `nl` — the check that catches a missed `CatalogTranslation` seed row;
|
||||
- an unresolved item raised inside an assignment carries that assignment's index, and one
|
||||
raised outside any assignment does not.
|
||||
The pre-existing suites stay: `resolveToothIntent` in all four quadrants, out-of-range
|
||||
rejection, deciduous → unresolved, `resolveDueDate` per-locale week start, "this" vs "next",
|
||||
Jalali leap year and month-end, and connected-span validation.
|
||||
- `cd backend && npm run build` — cross-cutting backend gate.
|
||||
- `cd frontend && npx tsc --noEmit` — frontend gate.
|
||||
- Manual: fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked
|
||||
lab. Then specifically:
|
||||
- **past day** → both segments disabled, control still split (not absent);
|
||||
- **locale with no profile** → control renders unsplit, identical to today;
|
||||
- **fa vs en** → mic sits at the logical end in both, on the same side as the chip's
|
||||
trash;
|
||||
- **cancel mid-recording** → chip strip unchanged, no orphan detail;
|
||||
- **confirm** → always appends a new chip, whatever the active detail contains;
|
||||
- **Add half** → behaves exactly as it did before this change;
|
||||
- **tap 🎤 during the Lab wizard step** → confirm returns to the treatment step;
|
||||
- no layout shift in the header row on record start, stop, or the 2:00 auto-stop;
|
||||
- **hold past 2:00** → auto-stops and proceeds to processing, not an error;
|
||||
- **cancel during processing** → the vendor request is actually aborted;
|
||||
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact;
|
||||
- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still
|
||||
there. They live on the lab case, which the autosave effect does not watch, so this is
|
||||
the check that catches a lab draft left unsaved in component state;
|
||||
- **record straight after opening a visit**, while the blank chip is still untyped, and
|
||||
confirm with a lab ticked → no error toast: confirm detects the preview treatment and
|
||||
skips the lab-case save rather than posting an id the server has never seen;
|
||||
- **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows
|
||||
both, and the bulk «اعمال برای همه دندانها» select stays on its placeholder. Nothing may
|
||||
rewrite a per-tooth type the sheet already showed.
|
||||
- `cd backend && npm run prisma:migrate && npm run prisma:seed` — the new `CatalogEntityKind`
|
||||
values and their translation rows. The seed never wipes, so re-running it is safe.
|
||||
- `cd frontend && npx vitest run` — **new**. One dev dependency, one config, one script,
|
||||
covering the pure helpers only: `prosthesisTree.ts` (stack legality, `applyLeafToJobs`
|
||||
precedence, `toothRegionColors`) and `voiceReviewRows.ts` (row availability, the merged
|
||||
row, folding chips into the result). No React, no DOM. `CLAUDE.md` is updated in the same
|
||||
commit — "there are no frontend tests" stops being true.
|
||||
- `cd frontend && npx tsc --noEmit` — frontend type gate.
|
||||
- `cd frontend && npm run build` — production build.
|
||||
- ESLint on every touched file, no new warnings.
|
||||
|
||||
### Manual
|
||||
|
||||
fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked lab. Then:
|
||||
|
||||
**Carried forward, still required**
|
||||
|
||||
- **past day** → both segments disabled, control still split (not absent);
|
||||
- **locale with no profile** → control renders unsplit, identical to today;
|
||||
- **fa vs en** → mic sits at the logical end in both, on the same side as the chip's trash;
|
||||
- **cancel mid-recording** → chip strip unchanged, no orphan detail;
|
||||
- **confirm** → always appends a new chip, whatever the active detail contains;
|
||||
- **Add half** → behaves exactly as it did before this change;
|
||||
- no layout shift in the header row on record start, stop, or the 2:00 auto-stop;
|
||||
- **hold past 2:00** → auto-stops and proceeds to processing, not an error;
|
||||
- **cancel during processing** → the vendor request is actually aborted;
|
||||
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact;
|
||||
- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still
|
||||
there.
|
||||
|
||||
**New to this revision**
|
||||
|
||||
- **Safari on macOS, and Safari on iPad** → the mic records, and the clip reaches the server
|
||||
as `m4a`. This is the report that started the revision;
|
||||
- **a browser with no `MediaRecorder`** → the Add button renders unsplit, and no mic appears;
|
||||
- **stack** — "دندون ۱۲ ایمپلنت با روکش زیرکونیا" → the sheet shows both jobs on 12, the chart
|
||||
colours crown and root differently, and the chart after apply shows the same stack;
|
||||
- **jaw appliance** — "نایت گارد فک بالا" → the sheet shows an upper-jaw row, the treatment
|
||||
type row reads *prosthesis* and is locked, and apply produces a `UA` row with no teeth;
|
||||
- **jaw not spoken** — "نایت گارد" → Upper / Lower chips; tapping both produces `UA` and `LA`;
|
||||
- **material not spoken** — "دندون ۱۲ روکش" → chips for the nine crown leaves, and the row
|
||||
reads *روکش* in Persian, not `crown`. Nothing is applied until one is picked;
|
||||
- **quadrant chip on a prosthesis detail** — "دندون دو روکش زیرکونیا" → picking a quadrant chip
|
||||
produces a tooth **carrying that assignment's job**, not a jobless tooth that is then
|
||||
discarded;
|
||||
- **tooth with no job** — "۱۲ و ۱۳، روکش پیافام برای ۱۲" → 13 is struck through in the sheet,
|
||||
apply adds only 12, and 13 is **not** silently saved and then deleted;
|
||||
- **illegal stack** — "دندون ۱۲ ایمپلنت و پست و کور" → the refused job is struck through and
|
||||
named; apply writes only the legal one, and the manual chart agrees;
|
||||
- **contradiction** — "دندون ۱۲ نایت گارد" → reported as `code_not_valid_for_target`, nothing
|
||||
applied for that assignment;
|
||||
- **two different materials** — "۱۲ روکش پیافام، ۱۳ روکش پیافزد" → the form shows both.
|
||||
Nothing may rewrite a per-tooth type the sheet already showed;
|
||||
- **non-lab-dependent type** — "ترمیم برای دندون ۱۴" → a plain teeth row, no prosthesis row,
|
||||
and the teeth survive the save.
|
||||
|
||||
---
|
||||
|
||||
@@ -766,14 +1095,14 @@ Settled in a grilling session on 2026-08-20.
|
||||
| 7 | Due date | Intent + deterministic resolver |
|
||||
| 8 | Resolver location | Backend, Jalali math ported |
|
||||
| 9 | Prosthesis | Default type + overrides, all-or-nothing |
|
||||
| 10 | Retention | Discard audio and transcript, non-PHI telemetry only |
|
||||
| 10 | Retention | Discard audio and transcript, non-PHI telemetry only — **partly superseded by 52**: the transcript is now logged on the server at info level |
|
||||
| 11 | Capture | Tap to start/stop, hard cap (see 27) |
|
||||
| 12 | Failure UX | Stage-aware codes, transcript salvage |
|
||||
| 13 | Locales | Provider registry per locale; all three locales enabled |
|
||||
| 14 | Reachability | Registry now, slots filled per deployment |
|
||||
| 23 | ASR model | `openai/whisper-1` for **every** locale; registry kept so `fa` can diverge |
|
||||
| 24 | Extraction model | **`google/gemini-3.7-flash`**; escalation path documented in §4 |
|
||||
| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail |
|
||||
| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail. **Superseded by 53**: salvage is dropped |
|
||||
| 26 | Throttle | Configurable; v1 default 6 requests / 60s per user |
|
||||
| 27 | Duration cap | **2 minutes**, configurable via `maxMs` |
|
||||
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part |
|
||||
@@ -800,3 +1129,49 @@ UI placement settled in a second grilling session on 2026-08-20.
|
||||
| 20 | Side | Logical end (`border-s`), exactly like the chip's trash |
|
||||
| 21 | Creation | On confirm, never on tap |
|
||||
| 22 | Creation rule | Confirm **always** appends a new detail — no blank-reuse guard |
|
||||
|
||||
Adapting to the overhauled treatment form, in a grilling session on 2026-09-07.
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| 34 | Scope | Full parity — voice reaches stacked jobs, jaw-level appliances and the category tree |
|
||||
| 35 | Intent shape | One `prosthesis` assignment list. `prosthesisDefaultType` + `prosthesisOverrides` are removed, and with them the precedence rule that mis-filled tooth 13 (§5) |
|
||||
| 36 | Arch targets | Derived, not declared — an arch with no position, confirmed against the code's `chartRegion`. `arch` gains `'both'` (§5) |
|
||||
| 37 | Partial codes | `types[]` may hold a category or subcategory code; the namespaces are disjoint and a test asserts it (§5) |
|
||||
| 38 | Stack legality | Frontend only, in `prosthesisTree.ts`. Both the sheet and the apply path route through `applyLeafToJobs`; the backend never learns the rules (§6) |
|
||||
| 39 | Sheet rows | Teeth and prosthesis merge into one row for lab-dependent types — they are not independent, and two ticks could save an empty detail (§7) |
|
||||
| 40 | Tooth with no job | Named and left out. The manual chart cannot produce a jobless tooth, so voice must not either (§6) |
|
||||
| 41 | Type coupling | A resolved assignment forces `treatmentType` to `prosthesis` and locks that row (§5) |
|
||||
| 42 | Missing material | Chips for the category's leaves. Never a per-category default — that is the auto-fill shape this design rejects (§7) |
|
||||
| 43 | Missing jaw | Upper / Lower chips, multi-pick. Picking both is how a both-jaw appliance is expressed (§7) |
|
||||
| 44 | Frontend tests | Vitest added for the pure helpers. The split between backend and frontend resolvers becomes a judgement rather than a constraint (§6, §12) |
|
||||
| 45 | Recording defects | Carried in this branch: the container fallback that refused Safari, and the render gate that never checked `isMediaRecorderSupported()` (§2, §9) |
|
||||
| 46 | Delivery | One merge request |
|
||||
|
||||
Closing the gaps the `/orchestrate` surveyor found on 2026-09-07, before any code was written.
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| 47 | Category labels | Add `PROSTHESIS_CATEGORY` and `PROSTHESIS_SUBCATEGORY` to `CatalogEntityKind`, one migration, translations seeded from the existing `prosthesis.category_*` / `sub_*` message keys, resolved through `CatalogLabelService`. Bare codes were the alternative and would have been weakest on `fa` (§5) |
|
||||
| 48 | Frontend label source | Out of scope. The frontend keeps its own message keys; the wording lives in two places by choice, and the seed is written from the message files so they agree (§5) |
|
||||
| 49 | Mixed-region categories | A category whose leaves span `crown` and `arch` — only `removable` today — defers its region check to the picked leaf, and is never reported as `code_not_valid_for_target` while still a category (§5) |
|
||||
| 50 | Chips that came from an assignment | `tooth_missing_quadrant`, `arch_not_spoken` and `prosthesis_type_ambiguous` carry `assignmentIndex`, so a picked chip inherits that assignment's jobs. Without it the chip resolves to a jobless tooth, which decision 40 discards — a chip that does nothing (§6) |
|
||||
|
||||
Corrections to the v1 text found in the same pass: the endpoint is `POST /voice/extract`, not
|
||||
`/treatments/voice-extract` (§3); `GET /voice/availability` does exist and only the plan check is
|
||||
deferred (§11 item 13); the catalog has 5 subcategories, not 4, and the disjointness test asserts
|
||||
against the live catalog rather than a written count (§5).
|
||||
|
||||
Transcript handling revised on 2026-09-10.
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| 51 | Who sees the transcript | Nobody outside the server. It is absent from the success response and from every error body, and the review sheet does not render it — a raw dictation can carry the patient's spoken name, and what is not sent cannot leak through the network tab or an error reporter (§7, §10) |
|
||||
| 52 | Where it goes instead | One **info**-level server log line per recording, written before extraction so a failed extraction still records it, and outside `logTelemetry` so that method stays patient-free. Accepted consequence: patient words persist in production logs at default level; `debug` is a one-word change (§10) |
|
||||
| 53 | Transcript salvage | Dropped, not deferred. Reversing it needs a decision about the transcript leaving the server, not just client code. This supersedes decision 25 (§9) |
|
||||
|
||||
Notes made explicit on 2026-09-10.
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| 54 | What becomes a note | Only what the clinician explicitly asked to be written. The model reports the asking words in `commentTrigger`; the resolver discards `comment` without one, so a prompt drift cannot quietly persist unrequested speech as a clinical note (§5) |
|
||||
|
||||
@@ -926,20 +926,26 @@
|
||||
"voiceProcessing": "Reading the recording…",
|
||||
"voiceReviewTitle": "Check what was understood",
|
||||
"voiceNothingExtracted": "Nothing usable was picked up from that recording.",
|
||||
"voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.",
|
||||
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
|
||||
"voiceNotUnderstood": "Not understood",
|
||||
"voicePickTooth": "Which tooth?",
|
||||
"voicePickJaw": "Which jaw?",
|
||||
"voiceTypeForcedByProsthesis": "Prosthesis work sets this type — it cannot be unticked.",
|
||||
"voiceTeethAndProsthesis": "Teeth and prosthesis",
|
||||
"voiceNoProsthesisHeard": "no prosthesis heard",
|
||||
"voiceStackRefused": "not added — cannot be combined",
|
||||
"voiceDiscard": "Discard",
|
||||
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
|
||||
"voiceUnresolved": {
|
||||
"not_permanent_tooth": "not a permanent tooth",
|
||||
"position_out_of_range": "not a valid tooth position",
|
||||
"tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”",
|
||||
"prosthesis_type_ambiguous": "a general term, not a specific material — pick one below",
|
||||
"arch_not_spoken": "which jaw was this for? — pick below",
|
||||
"code_not_valid_for_target": "does not fit that tooth or jaw",
|
||||
"malformed": "could not be read",
|
||||
"span_not_same_arch": "a bridge cannot span both jaws",
|
||||
"unknown_catalog_code": "not in this clinic’s list",
|
||||
"tooth_not_selected": "that tooth is not part of this detail",
|
||||
"invalid_date": "not a usable date"
|
||||
},
|
||||
"voiceFailed": "Voice entry failed. Please try again."
|
||||
|
||||
@@ -927,20 +927,26 @@
|
||||
"voiceProcessing": "در حال پردازش گفتار…",
|
||||
"voiceReviewTitle": "بررسی آنچه دریافت شد",
|
||||
"voiceNothingExtracted": "از این ضبط چیز قابل استفادهای برداشت نشد.",
|
||||
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندانها نوع داشته باشند، کیس ارسال نمیشود.",
|
||||
"voiceLabInexact": "نام گفتهشده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
|
||||
"voiceNotUnderstood": "شناسایی نشد",
|
||||
"voicePickTooth": "کدام دندان؟",
|
||||
"voicePickJaw": "کدام فک؟",
|
||||
"voiceTypeForcedByProsthesis": "کار پروتز این نوع درمان را تعیین میکند — قابل برداشتن نیست.",
|
||||
"voiceTeethAndProsthesis": "دندانها و پروتز",
|
||||
"voiceNoProsthesisHeard": "پروتزی شنیده نشد",
|
||||
"voiceStackRefused": "افزوده نشد — قابل ترکیب نیست",
|
||||
"voiceDiscard": "انصراف",
|
||||
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
|
||||
"voiceUnresolved": {
|
||||
"not_permanent_tooth": "دندان دائمی نیست",
|
||||
"position_out_of_range": "شماره دندان معتبر نیست",
|
||||
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
|
||||
"prosthesis_type_ambiguous": "یک عنوان کلی است، نه یک متریال مشخص — یکی را از پایین انتخاب کنید",
|
||||
"arch_not_spoken": "برای کدام فک بود؟ — از پایین انتخاب کنید",
|
||||
"code_not_valid_for_target": "با این دندان یا فک همخوانی ندارد",
|
||||
"malformed": "قابل خواندن نبود",
|
||||
"span_not_same_arch": "بریج نمیتواند بین دو فک باشد",
|
||||
"unknown_catalog_code": "در فهرست این مطب نیست",
|
||||
"tooth_not_selected": "این دندان بخشی از این مورد نیست",
|
||||
"invalid_date": "تاریخ قابل استفاده نیست"
|
||||
},
|
||||
"voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید."
|
||||
|
||||
@@ -926,20 +926,26 @@
|
||||
"voiceProcessing": "Opname wordt gelezen…",
|
||||
"voiceReviewTitle": "Controleer wat is begrepen",
|
||||
"voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.",
|
||||
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
|
||||
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
|
||||
"voiceNotUnderstood": "Niet begrepen",
|
||||
"voicePickTooth": "Welk element?",
|
||||
"voicePickJaw": "Welke kaak?",
|
||||
"voiceTypeForcedByProsthesis": "Prothesewerk bepaalt dit type — dit kan niet worden uitgevinkt.",
|
||||
"voiceTeethAndProsthesis": "Elementen en prothese",
|
||||
"voiceNoProsthesisHeard": "geen prothese gehoord",
|
||||
"voiceStackRefused": "niet toegevoegd — kan niet worden gecombineerd",
|
||||
"voiceDiscard": "Verwerpen",
|
||||
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
|
||||
"voiceUnresolved": {
|
||||
"not_permanent_tooth": "geen blijvend element",
|
||||
"position_out_of_range": "geen geldige elementpositie",
|
||||
"tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”",
|
||||
"prosthesis_type_ambiguous": "een algemene term, geen specifiek materiaal — kies hieronder",
|
||||
"arch_not_spoken": "voor welke kaak was dit? — kies hieronder",
|
||||
"code_not_valid_for_target": "past niet bij dat element of die kaak",
|
||||
"malformed": "kon niet worden gelezen",
|
||||
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
|
||||
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
|
||||
"tooth_not_selected": "dat element hoort niet bij dit onderdeel",
|
||||
"invalid_date": "geen bruikbare datum"
|
||||
},
|
||||
"voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw."
|
||||
|
||||
1154
frontend/package-lock.json
generated
1154
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -36,6 +37,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
203
frontend/src/components/treatment/prosthesisTree.spec.ts
Normal file
203
frontend/src/components/treatment/prosthesisTree.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
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,
|
||||
removeTeethFromGroups,
|
||||
toothRegionColors,
|
||||
} from './prosthesisTree';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
import type { ToothSelectionGroup } from './toothSelectionGroups';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTeethFromGroups', () => {
|
||||
const bridge: ToothSelectionGroup = {
|
||||
groupId: 'g1',
|
||||
kind: 'connected',
|
||||
teeth: ['14', '13', '12'] as FdiToothId[],
|
||||
};
|
||||
|
||||
it('does not leave a pontic-less bridge when a middle tooth goes', () => {
|
||||
// pruneDetailTeethToJobs routes through here, so this is the voice path: a job removed
|
||||
// from 13 must not leave 12 and 14 wired together as one unit.
|
||||
const out = removeTeethFromGroups([bridge], ['13'] as FdiToothId[]);
|
||||
expect(out.every((g) => g.kind === 'single')).toBe(true);
|
||||
expect(new Set(out.flatMap((g) => g.teeth))).toEqual(new Set(['12', '14']));
|
||||
expect(new Set(out.map((g) => g.groupId)).size).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the remaining span when an end tooth goes', () => {
|
||||
const out = removeTeethFromGroups([bridge], ['12'] as FdiToothId[]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].kind).toBe('connected');
|
||||
expect(out[0].teeth).toEqual(['14', '13']);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deriveTeethFromGroups,
|
||||
groupsFromFlatTeeth,
|
||||
newGroupId,
|
||||
splitDisconnectedRuns,
|
||||
} from '@/components/treatment/toothSelectionGroups';
|
||||
|
||||
const PROSTHESIS_CATEGORY_ORDER = [
|
||||
@@ -337,11 +338,9 @@ export function removeTeethFromGroups(
|
||||
for (const group of groups) {
|
||||
const kept = group.teeth.filter((t) => !drop.has(t));
|
||||
if (kept.length === 0) continue;
|
||||
if (kept.length === 1) {
|
||||
next.push({ groupId: group.groupId, kind: 'single', teeth: kept });
|
||||
} else {
|
||||
next.push({ ...group, teeth: kept, kind: group.kind === 'connected' ? 'connected' : 'single' });
|
||||
}
|
||||
// Removing a middle tooth can leave a connected group non-contiguous, which is no longer
|
||||
// one bridge. splitDisconnectedRuns keeps the runs that are still spans.
|
||||
next.push(...splitDisconnectedRuns({ ...group, teeth: kept }));
|
||||
}
|
||||
return next.length > 0 ? next : groupsFromFlatTeeth([]);
|
||||
}
|
||||
|
||||
131
frontend/src/components/treatment/toothSelectionGroups.spec.ts
Normal file
131
frontend/src/components/treatment/toothSelectionGroups.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
import type { ToothSelectionGroup } from './toothSelectionGroups';
|
||||
import {
|
||||
areArchNeighbors,
|
||||
connectedTeethSet,
|
||||
groupsFromFlatTeeth,
|
||||
linkedEdgesFromGroups,
|
||||
splitDisconnectedRuns,
|
||||
} from './toothSelectionGroups';
|
||||
|
||||
const bridge = (teeth: string[], groupId = 'g1'): ToothSelectionGroup => ({
|
||||
groupId,
|
||||
kind: 'connected',
|
||||
teeth: teeth as FdiToothId[],
|
||||
});
|
||||
|
||||
describe('splitDisconnectedRuns', () => {
|
||||
it('leaves a contiguous bridge alone, keeping its id', () => {
|
||||
// 14-13-12 are consecutive in FDI_UPPER_LEFT_TO_RIGHT.
|
||||
const out = splitDisconnectedRuns(bridge(['12', '13', '14']));
|
||||
expect(out).toEqual([
|
||||
{ groupId: 'g1', kind: 'connected', teeth: ['14', '13', '12'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits a bridge that lost its middle tooth into two singles', () => {
|
||||
// The reported case: a 12-13-14 span reduced to 12 and 14 is not a bridge, it is two
|
||||
// separate crowns. 13 is the pontic, and it is gone.
|
||||
const out = splitDisconnectedRuns(bridge(['12', '14']));
|
||||
expect(out.map((g) => [g.kind, g.teeth])).toEqual([
|
||||
['single', ['14']],
|
||||
['single', ['12']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps each side that is still a span', () => {
|
||||
// 16-15-14 | gap at 13 | 12-11
|
||||
const out = splitDisconnectedRuns(bridge(['11', '12', '14', '15', '16']));
|
||||
expect(out.map((g) => [g.kind, g.teeth])).toEqual([
|
||||
['connected', ['16', '15', '14']],
|
||||
['connected', ['12', '11']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives the first run the original id and the rest fresh ids', () => {
|
||||
const out = splitDisconnectedRuns(bridge(['11', '12', '14', '15'], 'keep-me'));
|
||||
expect(out[0].groupId).toBe('keep-me');
|
||||
expect(out[1].groupId).not.toBe('keep-me');
|
||||
expect(out[1].groupId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('never emits a one-tooth connected group', () => {
|
||||
const out = splitDisconnectedRuns(bridge(['12']));
|
||||
expect(out).toEqual([{ groupId: 'g1', kind: 'single', teeth: ['12'] }]);
|
||||
});
|
||||
|
||||
it('splits a bridge spanning two arches, which is never one span', () => {
|
||||
// areArchNeighbors is false across arches, so an upper tooth and a lower one cannot be
|
||||
// consecutive whatever their numbers.
|
||||
const out = splitDisconnectedRuns(bridge(['12', '42']));
|
||||
expect(out.every((g) => g.kind === 'single')).toBe(true);
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('passes a single group through untouched', () => {
|
||||
const single: ToothSelectionGroup = {
|
||||
groupId: 'g2',
|
||||
kind: 'single',
|
||||
teeth: ['26'] as FdiToothId[],
|
||||
};
|
||||
expect(splitDisconnectedRuns(single)).toEqual([single]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupsFromFlatTeeth', () => {
|
||||
it('does not keep a bridge across a tooth that is no longer selected', () => {
|
||||
// This is what produced a pontic-less bridge: the filter dropped 13 and the group kept
|
||||
// its `connected` kind, so both crowns shared one selectionGroupId.
|
||||
const out = groupsFromFlatTeeth(['12', '14'] as FdiToothId[], [
|
||||
bridge(['12', '13', '14']),
|
||||
]);
|
||||
expect(out.some((g) => g.kind === 'connected')).toBe(false);
|
||||
expect(connectedTeethSet(out).size).toBe(0);
|
||||
expect(new Set(out.flatMap((g) => g.teeth))).toEqual(new Set(['12', '14']));
|
||||
// Two crowns must not share a selection group, or the lab builds them as one unit.
|
||||
expect(new Set(out.map((g) => g.groupId)).size).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps a bridge whose teeth all survive', () => {
|
||||
const out = groupsFromFlatTeeth(['12', '13', '14'] as FdiToothId[], [
|
||||
bridge(['12', '13', '14']),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].kind).toBe('connected');
|
||||
});
|
||||
|
||||
it('adds a tooth no existing group covers', () => {
|
||||
const out = groupsFromFlatTeeth(['12', '13', '26'] as FdiToothId[], [
|
||||
bridge(['12', '13']),
|
||||
]);
|
||||
expect(connectedTeethSet(out)).toEqual(new Set(['12', '13']));
|
||||
expect(out.find((g) => g.teeth.includes('26' as FdiToothId))?.kind).toBe('single');
|
||||
});
|
||||
|
||||
it('makes one single group per tooth when there is nothing to preserve', () => {
|
||||
const out = groupsFromFlatTeeth(['12', '14'] as FdiToothId[]);
|
||||
expect(out.map((g) => g.kind)).toEqual(['single', 'single']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkedEdgesFromGroups', () => {
|
||||
it('draws no edge across a gap, so the split matches what was already drawn', () => {
|
||||
// The marks were already correct before the split — only the group kind and its shared
|
||||
// id were wrong. This pins that the two now agree.
|
||||
expect(linkedEdgesFromGroups([bridge(['12', '14'])]).size).toBe(0);
|
||||
expect(linkedEdgesFromGroups([bridge(['12', '13'])]).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areArchNeighbors', () => {
|
||||
it('reads adjacency from arch order, not from the numbers', () => {
|
||||
// 12 and 13 are neighbours; 12 and 14 are two apart with 13 between them.
|
||||
expect(areArchNeighbors('12' as FdiToothId, '13' as FdiToothId)).toBe(true);
|
||||
expect(areArchNeighbors('12' as FdiToothId, '14' as FdiToothId)).toBe(false);
|
||||
// Across the midline, 11 and 21 are adjacent even though the numbers jump.
|
||||
expect(areArchNeighbors('11' as FdiToothId, '21' as FdiToothId)).toBe(true);
|
||||
// Across arches, never.
|
||||
expect(areArchNeighbors('12' as FdiToothId, '42' as FdiToothId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -58,11 +58,8 @@ export function groupsFromFlatTeeth(
|
||||
teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[],
|
||||
}))
|
||||
.filter((g) => g.teeth.length > 0)
|
||||
.map((g) => ({
|
||||
...g,
|
||||
kind: normalizeGroupKind(g.kind, g.teeth),
|
||||
teeth: sortInArchOrder(g.teeth),
|
||||
}));
|
||||
.map((g) => ({ ...g, teeth: sortInArchOrder(g.teeth) }))
|
||||
.flatMap(splitDisconnectedRuns);
|
||||
const covered = new Set(next.flatMap((g) => g.teeth));
|
||||
for (const tooth of teeth) {
|
||||
if (!covered.has(tooth)) {
|
||||
@@ -121,6 +118,37 @@ export function areArchNeighbors(a: FdiToothId, b: FdiToothId): boolean {
|
||||
return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bridge is a contiguous span, so a connected group that has lost a middle tooth is no longer
|
||||
* one bridge. `linkedEdgesFromGroups` already refuses to draw an edge between non-neighbours, but
|
||||
* the group keeps its `connected` kind and its single `groupId` — and that id becomes the
|
||||
* `selectionGroupId` on every lab row, so task generation would build one unit spanning the gap.
|
||||
* A "bridge" from 12 to 14 with no pontic is work no lab can make.
|
||||
*
|
||||
* Split what is left into contiguous runs; a run of one tooth becomes a single. The first run
|
||||
* keeps the original `groupId`, so lab rows already pointing at it stay valid, and
|
||||
* `pruneToothProsthesisForGroups` re-maps the rest.
|
||||
*/
|
||||
export function splitDisconnectedRuns(group: ToothSelectionGroup): ToothSelectionGroup[] {
|
||||
if (group.kind !== 'connected' || group.teeth.length < 2) {
|
||||
return [{ ...group, kind: normalizeGroupKind(group.kind, group.teeth) }];
|
||||
}
|
||||
const ordered = sortInArchOrder(group.teeth);
|
||||
const runs: FdiToothId[][] = [[ordered[0]]];
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
if (areArchNeighbors(ordered[i - 1], ordered[i])) {
|
||||
runs[runs.length - 1].push(ordered[i]);
|
||||
} else {
|
||||
runs.push([ordered[i]]);
|
||||
}
|
||||
}
|
||||
return runs.map((teeth, index) => ({
|
||||
groupId: index === 0 ? group.groupId : newGroupId(),
|
||||
kind: teeth.length >= 2 ? ('connected' as const) : ('single' as const),
|
||||
teeth,
|
||||
}));
|
||||
}
|
||||
|
||||
function archOrder(tooth: FdiToothId): FdiToothId[] | null {
|
||||
if ((FDI_UPPER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) {
|
||||
return FDI_UPPER_LEFT_TO_RIGHT;
|
||||
@@ -285,10 +313,7 @@ export function applyShiftRange(
|
||||
teeth: g.teeth.filter((t) => !union.has(t)) as FdiToothId[],
|
||||
}))
|
||||
.filter((g) => g.teeth.length > 0)
|
||||
.map((g) => ({
|
||||
...g,
|
||||
kind: normalizeGroupKind(g.kind, g.teeth),
|
||||
}));
|
||||
.flatMap(splitDisconnectedRuns);
|
||||
|
||||
for (const tooth of unionTeeth) {
|
||||
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
|
||||
|
||||
273
frontend/src/components/treatment/voiceReviewRows.spec.ts
Normal file
273
frontend/src/components/treatment/voiceReviewRows.spec.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
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 {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,73 @@
|
||||
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
|
||||
import {
|
||||
ARCH_TOOTH_LOWER,
|
||||
ARCH_TOOTH_UPPER,
|
||||
applyLeafToJobs,
|
||||
archSentinels,
|
||||
canStackLeaf,
|
||||
catalogByCode,
|
||||
isArchSentinel,
|
||||
toothRegionColors,
|
||||
type ArchTarget,
|
||||
} from '@/components/treatment/prosthesisTree';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type {
|
||||
VoiceApplySelection,
|
||||
VoiceExtractionResult,
|
||||
VoiceProsthesisResult,
|
||||
VoiceProsthesisAssignment,
|
||||
VoiceUnresolvedItem,
|
||||
} from '@/types/voice';
|
||||
|
||||
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
|
||||
export function voiceRowAvailability(result: VoiceExtractionResult) {
|
||||
/** `prosthesis` is the app's only labDependent treatment type today, but this stays generic. */
|
||||
export function isLabDependentResult(
|
||||
result: VoiceExtractionResult,
|
||||
labDependentCodes: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return Boolean(result.treatmentType && labDependentCodes.has(result.treatmentType));
|
||||
}
|
||||
|
||||
function hasProsthesisWork(result: VoiceExtractionResult): boolean {
|
||||
return result.prosthesisAssignments.some((a) => a.targets.length > 0 || a.types.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which rows the review sheet renders at all — a row with nothing extracted is noise.
|
||||
*
|
||||
* Teeth and prosthesis are never both available: a lab-dependent type merges them into one
|
||||
* `prosthesis` row (decision 39), because two independent ticks can save an empty detail —
|
||||
* `persistDraft` prunes a lab-dependent detail to its jobs.
|
||||
*/
|
||||
export function voiceRowAvailability(
|
||||
result: VoiceExtractionResult,
|
||||
labDependentCodes: ReadonlySet<string>,
|
||||
) {
|
||||
const labDependent = isLabDependentResult(result, labDependentCodes);
|
||||
return {
|
||||
treatmentType: result.treatmentType != null,
|
||||
teeth: result.teeth.length > 0,
|
||||
teeth: !labDependent && result.teeth.length > 0,
|
||||
prosthesis: labDependent && (result.teeth.length > 0 || hasProsthesisWork(result)),
|
||||
comment: Boolean(result.comment?.trim()),
|
||||
prosthesis: result.prosthesis != null,
|
||||
lab: result.labId != null,
|
||||
dueDate: result.dueDate != null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
|
||||
* it is the one extracted value whose error leaves the building; and an incomplete
|
||||
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
|
||||
* Everything available ticks itself, with one exception: an inexactly-matched lab, because it
|
||||
* is the one extracted value whose error leaves the building. All-or-nothing prosthesis maps
|
||||
* are retired (decision 40) — an incomplete stack no longer blocks a tick.
|
||||
*/
|
||||
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
|
||||
const available = voiceRowAvailability(result);
|
||||
export function initialVoiceSelection(
|
||||
result: VoiceExtractionResult,
|
||||
labDependentCodes: ReadonlySet<string>,
|
||||
): VoiceApplySelection {
|
||||
const available = voiceRowAvailability(result, labDependentCodes);
|
||||
return {
|
||||
treatmentType: available.treatmentType,
|
||||
teeth: available.teeth,
|
||||
comment: available.comment,
|
||||
prosthesis: available.prosthesis && result.prosthesis?.complete === true,
|
||||
prosthesis: available.prosthesis,
|
||||
lab: available.lab && result.labMatchExact,
|
||||
dueDate: available.dueDate,
|
||||
};
|
||||
@@ -48,37 +86,75 @@ export function countSelected(
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */
|
||||
function recheckProsthesis(
|
||||
prosthesis: VoiceProsthesisResult,
|
||||
teeth: readonly FdiToothId[],
|
||||
): VoiceProsthesisResult {
|
||||
const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]);
|
||||
return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 };
|
||||
/** Every resolved assignment target, across every assignment. */
|
||||
function allAssignmentTargets(result: VoiceExtractionResult): Set<string> {
|
||||
return new Set(result.prosthesisAssignments.flatMap((a) => a.targets));
|
||||
}
|
||||
|
||||
/** Forces treatmentType to `prosthesis` the moment any assignment carries a real target. */
|
||||
function withProsthesisForced(result: VoiceExtractionResult): VoiceExtractionResult {
|
||||
const forced = result.prosthesisAssignments.some((a) => a.targets.length > 0);
|
||||
return forced ? { ...result, treatmentType: 'prosthesis' } : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
|
||||
* Fold a picked FDI/quadrant candidate into the result.
|
||||
*
|
||||
* `assignmentIndex` set: the chip came from resolving a `prosthesisAssignments` entry, so the
|
||||
* pick becomes that assignment's target — inheriting its `types`, not a jobless tooth
|
||||
* (decision 50). `assignmentIndex` absent: the chip came from the plain `teeth` list and folds
|
||||
* in there, exactly as before.
|
||||
*
|
||||
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
|
||||
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
|
||||
* produced, and tapping it must not deselect that one.
|
||||
*/
|
||||
export function withChosenTeeth(
|
||||
result: VoiceExtractionResult,
|
||||
chosen: readonly FdiToothId[],
|
||||
assignmentIndex?: number,
|
||||
): VoiceExtractionResult {
|
||||
if (chosen.length === 0) return result;
|
||||
|
||||
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
|
||||
if (assignmentIndex != null) {
|
||||
const assignments = result.prosthesisAssignments.map((a, i) =>
|
||||
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, ...chosen])] } : a,
|
||||
);
|
||||
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
|
||||
}
|
||||
|
||||
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
|
||||
return {
|
||||
...result,
|
||||
teeth,
|
||||
toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups),
|
||||
prosthesis: result.prosthesis ? recheckProsthesis(result.prosthesis, teeth) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fold a picked jaw ('upper' / 'lower') into the assignment that named no jaw at all. */
|
||||
export function withChosenArch(
|
||||
result: VoiceExtractionResult,
|
||||
assignmentIndex: number,
|
||||
arch: 'upper' | 'lower',
|
||||
): VoiceExtractionResult {
|
||||
const sentinel = arch === 'upper' ? ARCH_TOOTH_UPPER : ARCH_TOOTH_LOWER;
|
||||
const assignments = result.prosthesisAssignments.map((a, i) =>
|
||||
i === assignmentIndex ? { ...a, targets: [...new Set([...a.targets, sentinel])] } : a,
|
||||
);
|
||||
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
|
||||
}
|
||||
|
||||
/** Fold a picked leaf into the assignment that only named a category or subcategory. */
|
||||
export function withChosenProsthesisLeaf(
|
||||
result: VoiceExtractionResult,
|
||||
assignmentIndex: number,
|
||||
leafCode: string,
|
||||
): VoiceExtractionResult {
|
||||
const assignments = result.prosthesisAssignments.map((a, i) =>
|
||||
i === assignmentIndex ? { ...a, types: [...new Set([...a.types, leafCode])] } : a,
|
||||
);
|
||||
return withProsthesisForced({ ...result, prosthesisAssignments: assignments });
|
||||
}
|
||||
|
||||
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
|
||||
export function connectedTeethFromResult(result: VoiceExtractionResult): Set<FdiToothId> {
|
||||
const connected = new Set<FdiToothId>();
|
||||
@@ -89,7 +165,138 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
|
||||
return connected;
|
||||
}
|
||||
|
||||
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
|
||||
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
|
||||
return Object.values(voiceRowAvailability(result)).some(Boolean);
|
||||
export type VoiceProsthesisTargetLine = {
|
||||
target: string;
|
||||
isJaw: boolean;
|
||||
/** Leaf codes that will actually land, in landing order. */
|
||||
applied: string[];
|
||||
/** Leaf codes the stack rules refused — shown struck through, never silently dropped. */
|
||||
refused: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The stack that will actually land, built through `applyLeafToJobs` — the same function the
|
||||
* manual chart writes through (§7). A code the stack rules refuse (an implant plus a post &
|
||||
* core on one tooth) is named as refused rather than silently dropped or silently applied.
|
||||
*/
|
||||
export function prosthesisTargetLines(
|
||||
assignments: readonly VoiceProsthesisAssignment[],
|
||||
catalog: readonly ProsthesisCatalogEntry[],
|
||||
): VoiceProsthesisTargetLine[] {
|
||||
const byCode = catalogByCode(catalog);
|
||||
const byTarget = new Map<string, { applied: string[]; refused: string[] }>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
for (const target of assignment.targets) {
|
||||
const entry = byTarget.get(target) ?? { applied: [], refused: [] };
|
||||
for (const code of assignment.types) {
|
||||
if (canStackLeaf(entry.applied, code, byCode)) {
|
||||
entry.applied = applyLeafToJobs(entry.applied, code, byCode);
|
||||
} else {
|
||||
entry.refused.push(code);
|
||||
}
|
||||
}
|
||||
byTarget.set(target, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byTarget.entries()].map(([target, { applied, refused }]) => ({
|
||||
target,
|
||||
isJaw: isArchSentinel(target),
|
||||
applied,
|
||||
refused,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* FDI codes / jaw sentinels named somewhere (an assignment target, or the plain `teeth` list)
|
||||
* but ending up with no job at all — struck through in the sheet reading "no prosthesis heard"
|
||||
* (decision 40). A target still pending a material pick (a `prosthesis_type_ambiguous` chip
|
||||
* for its assignment) is not jobless; it is simply not resolved yet.
|
||||
*/
|
||||
export function joblessProsthesisTargets(result: VoiceExtractionResult): string[] {
|
||||
const pendingIndexes = new Set(
|
||||
result.unresolved
|
||||
.filter((u) => u.reason === 'prosthesis_type_ambiguous' && u.assignmentIndex != null)
|
||||
.map((u) => u.assignmentIndex as number),
|
||||
);
|
||||
|
||||
const jobless = new Set<string>();
|
||||
const covered = new Set<string>();
|
||||
const pending = new Set<string>();
|
||||
|
||||
result.prosthesisAssignments.forEach((assignment, index) => {
|
||||
if (pendingIndexes.has(index)) {
|
||||
for (const target of assignment.targets) pending.add(target);
|
||||
} else if (assignment.types.length === 0) {
|
||||
for (const target of assignment.targets) jobless.add(target);
|
||||
} else {
|
||||
for (const target of assignment.targets) covered.add(target);
|
||||
}
|
||||
});
|
||||
|
||||
for (const tooth of result.teeth) {
|
||||
if (!covered.has(tooth) && !pending.has(tooth)) jobless.add(tooth);
|
||||
}
|
||||
|
||||
return [...jobless];
|
||||
}
|
||||
|
||||
export type VoiceProsthesisChartData = {
|
||||
crownColors: Partial<Record<FdiToothId, string>>;
|
||||
rootColors: Partial<Record<FdiToothId, string>>;
|
||||
archHighlight: ArchTarget | null;
|
||||
/** Every real tooth involved — a target with a job, or a jobless one named alongside it. */
|
||||
selectedTeeth: Set<FdiToothId>;
|
||||
};
|
||||
|
||||
/** Feeds the merged row's `FdiToothChart` — crown/root tints plus the arch highlight. */
|
||||
export function prosthesisChartData(
|
||||
lines: readonly VoiceProsthesisTargetLine[],
|
||||
jobless: readonly string[],
|
||||
catalog: readonly ProsthesisCatalogEntry[],
|
||||
): VoiceProsthesisChartData {
|
||||
const rows = lines.flatMap((line) =>
|
||||
line.applied.map((code) => ({ tooth: line.target, prosthesisTypeCode: code })),
|
||||
);
|
||||
const { crown, root } = toothRegionColors(rows, catalog);
|
||||
|
||||
const hasUpper = lines.some((l) => l.target === ARCH_TOOTH_UPPER && l.applied.length > 0);
|
||||
const hasLower = lines.some((l) => l.target === ARCH_TOOTH_LOWER && l.applied.length > 0);
|
||||
const archHighlight: ArchTarget | null =
|
||||
hasUpper && hasLower ? 'both' : hasUpper ? 'upper' : hasLower ? 'lower' : null;
|
||||
|
||||
const selectedTeeth = new Set<FdiToothId>();
|
||||
for (const line of lines) {
|
||||
if (!line.isJaw) selectedTeeth.add(line.target as FdiToothId);
|
||||
}
|
||||
for (const tooth of jobless) {
|
||||
if (!isArchSentinel(tooth)) selectedTeeth.add(tooth as FdiToothId);
|
||||
}
|
||||
|
||||
return { crownColors: crown, rootColors: root, archHighlight, selectedTeeth };
|
||||
}
|
||||
|
||||
/** Which unresolved items belong to which chip section — the sheet renders both identically. */
|
||||
export function unresolvedWithoutAssignment(
|
||||
result: VoiceExtractionResult,
|
||||
): VoiceUnresolvedItem[] {
|
||||
return result.unresolved.filter((u) => u.assignmentIndex == null);
|
||||
}
|
||||
|
||||
export function unresolvedForAssignment(
|
||||
result: VoiceExtractionResult,
|
||||
assignmentIndex: number,
|
||||
): VoiceUnresolvedItem[] {
|
||||
return result.unresolved.filter((u) => u.assignmentIndex === assignmentIndex);
|
||||
}
|
||||
|
||||
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
|
||||
export function hasAnythingToApply(
|
||||
result: VoiceExtractionResult,
|
||||
labDependentCodes: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return Object.values(voiceRowAvailability(result, labDependentCodes)).some(Boolean);
|
||||
}
|
||||
|
||||
export { archSentinels, allAssignmentTargets };
|
||||
|
||||
@@ -31,6 +31,7 @@ import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import { voiceApi } from '@/lib/api/voice';
|
||||
import { useVoiceCapture } from '@/lib/voice/useVoiceCapture';
|
||||
import { isMediaRecorderSupported } from '@/lib/voice/audioFormat';
|
||||
import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet';
|
||||
import type {
|
||||
VoiceApplySelection,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
unlinkAdjacentTeeth,
|
||||
} from '@/components/treatment/toothSelectionGroups';
|
||||
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
|
||||
import { prosthesisTargetLines } from '@/components/treatment/voiceReviewRows';
|
||||
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
||||
import {
|
||||
@@ -549,6 +551,13 @@ export function TreatmentWorkspace({
|
||||
const draftHydratingRef = useRef(false);
|
||||
const workspaceModeRef = useRef(workspaceMode);
|
||||
workspaceModeRef.current = workspaceMode;
|
||||
/**
|
||||
* `persistDraft` prunes a lab-dependent detail down to the teeth its jobs cover, and it reads
|
||||
* this ref — not React state. So any handler that changes the drafts and then saves in the
|
||||
* same tick must write the ref beside `setLabCaseDrafts`; the render-time assignment below has
|
||||
* not run yet. Skipping it saves the detail with no teeth, and the lab-case request that
|
||||
* follows is then rejected with TREATMENT_TOOTH_NOT_ON_DETAIL.
|
||||
*/
|
||||
const labCaseDraftsRef = useRef(labCaseDrafts);
|
||||
labCaseDraftsRef.current = labCaseDrafts;
|
||||
const skipNextGetDraftRef = useRef(false);
|
||||
@@ -621,9 +630,18 @@ export function TreatmentWorkspace({
|
||||
onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))),
|
||||
});
|
||||
|
||||
/** Absence is the unavailable state — the Add button then renders unsplit. */
|
||||
/**
|
||||
* Absence is the unavailable state — the Add button then renders unsplit. Gated on both the
|
||||
* server's availability response AND the browser's own recording support: without the
|
||||
* latter check the control rendered on a browser that cannot record and failed on tap
|
||||
* (the Safari report that started this revision, §2).
|
||||
*/
|
||||
const voiceForEditor =
|
||||
voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined;
|
||||
voiceAvailability?.enabled &&
|
||||
voiceAvailability.locales.includes(locale) &&
|
||||
isMediaRecorderSupported()
|
||||
? voice
|
||||
: undefined;
|
||||
|
||||
const selectedStandalone = useMemo(
|
||||
() => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null,
|
||||
@@ -2192,7 +2210,16 @@ export function TreatmentWorkspace({
|
||||
if (selection.treatmentType && result.treatmentType) {
|
||||
detail.treatmentType = result.treatmentType;
|
||||
}
|
||||
if (selection.teeth) {
|
||||
|
||||
// Teeth and prosthesis are one row for a lab-dependent type (decision 39) — ticking
|
||||
// "teeth" independently of "prosthesis" could save an empty detail, since
|
||||
// `persistDraft` prunes a lab-dependent detail down to its jobs. `selection.prosthesis`
|
||||
// alone drives both below; `selection.teeth` only ever applies to the plain row.
|
||||
// Derived from the detail actually being written, never from `result.treatmentType`.
|
||||
// Reading the result meant that unticking the type row still took the prosthesis branch,
|
||||
// saving lab rows on whatever type the appointment purpose had seeded (decision 41).
|
||||
const labDependent = labDependentCodes.has(detail.treatmentType);
|
||||
if (!labDependent && selection.teeth) {
|
||||
detail.teeth = [...result.teeth];
|
||||
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
|
||||
...group,
|
||||
@@ -2203,6 +2230,25 @@ export function TreatmentWorkspace({
|
||||
detail.comment = result.comment;
|
||||
}
|
||||
|
||||
// The stack that will actually land, built through the same `applyLeafToJobs` the
|
||||
// manual chart writes through (§7) — a code the stack rules refuse is not applied.
|
||||
const prosthesisLines =
|
||||
labDependent && selection.prosthesis
|
||||
? prosthesisTargetLines(result.prosthesisAssignments, prosthesisCatalog)
|
||||
: [];
|
||||
// An assignment target is a selection: a tooth exists on a prosthesis detail only by
|
||||
// carrying a job (decision 40) — never from the plain `teeth` field.
|
||||
const prosthesisTeeth = prosthesisLines
|
||||
.filter((line) => !line.isJaw && line.applied.length > 0)
|
||||
.map((line) => line.target as FdiToothId);
|
||||
if (labDependent && selection.prosthesis) {
|
||||
detail.teeth = prosthesisTeeth;
|
||||
detail.toothSelectionGroups = groupsFromFlatTeeth(
|
||||
prosthesisTeeth,
|
||||
result.toothSelectionGroups,
|
||||
);
|
||||
}
|
||||
|
||||
const nextDetails = [...detailsRef.current, detail];
|
||||
setDetails(nextDetails);
|
||||
// persistDraft reads detailsRef, and setDetails has not rendered yet.
|
||||
@@ -2212,7 +2258,7 @@ export function TreatmentWorkspace({
|
||||
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
|
||||
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
|
||||
const wantsLabDraft =
|
||||
(selection.prosthesis && result.prosthesis) ||
|
||||
prosthesisLines.some((line) => line.applied.length > 0) ||
|
||||
(selection.lab && result.labId) ||
|
||||
(selection.dueDate && result.dueDate);
|
||||
|
||||
@@ -2225,27 +2271,21 @@ export function TreatmentWorkspace({
|
||||
if (selection.dueDate && result.dueDate) {
|
||||
draft.dueDate = result.dueDate;
|
||||
}
|
||||
if (selection.prosthesis && result.prosthesis) {
|
||||
// byTooth keys are plain strings; the group's teeth are FdiToothId.
|
||||
const groupOf = (tooth: string) =>
|
||||
result.toothSelectionGroups.find((group) =>
|
||||
(group.teeth as readonly string[]).includes(tooth),
|
||||
)?.groupId ?? '';
|
||||
// Only teeth that actually landed on the detail. Unticking "teeth" while
|
||||
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
|
||||
// the treatment does not contain — nothing downstream filters them, and they
|
||||
// would reach task generation as work for teeth nobody is treating.
|
||||
const detailTeeth = new Set<string>(detail.teeth);
|
||||
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
|
||||
.filter(([tooth]) => detailTeeth.has(tooth))
|
||||
.map(([tooth, prosthesisTypeCode]) => ({
|
||||
detailClientId: detail.clientId,
|
||||
tooth,
|
||||
prosthesisTypeCode,
|
||||
selectionGroupId: groupOf(tooth),
|
||||
}));
|
||||
}
|
||||
draft.toothProsthesis = prosthesisLines.flatMap((line) => {
|
||||
const groupId = line.isJaw
|
||||
? ''
|
||||
: (detail.toothSelectionGroups.find((group) =>
|
||||
(group.teeth as readonly string[]).includes(line.target),
|
||||
)?.groupId ?? '');
|
||||
return line.applied.map((prosthesisTypeCode) => ({
|
||||
detailClientId: detail.clientId,
|
||||
tooth: line.target,
|
||||
prosthesisTypeCode,
|
||||
selectionGroupId: groupId,
|
||||
}));
|
||||
});
|
||||
const updatedLabCases = [...labCaseDrafts, draft];
|
||||
labCaseDraftsRef.current = updatedLabCases;
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
|
||||
// Persist the new detail first so lab-case rows can use real treatmentDetailIds.
|
||||
@@ -2269,8 +2309,10 @@ export function TreatmentWorkspace({
|
||||
},
|
||||
[
|
||||
labCaseDrafts,
|
||||
labDependentCodes,
|
||||
persistDraft,
|
||||
persistLabCases,
|
||||
prosthesisCatalog,
|
||||
selectedAppointment?.purpose,
|
||||
showError,
|
||||
t,
|
||||
@@ -2398,6 +2440,7 @@ export function TreatmentWorkspace({
|
||||
const updatedLabCases = cleaned.map((lc) =>
|
||||
lc.clientId === orphan.clientId ? { ...lc, detailClientId: activeDetailId } : lc,
|
||||
);
|
||||
labCaseDraftsRef.current = updatedLabCases;
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
setActiveLabCaseId(orphan.clientId);
|
||||
|
||||
@@ -2416,6 +2459,7 @@ export function TreatmentWorkspace({
|
||||
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
|
||||
};
|
||||
const updatedLabCases = [...cleaned, next];
|
||||
labCaseDraftsRef.current = updatedLabCases;
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
setActiveLabCaseId(next.clientId);
|
||||
|
||||
@@ -3202,6 +3246,7 @@ export function TreatmentWorkspace({
|
||||
result={voiceResult}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
labDependentCodes={labDependentCodes}
|
||||
labs={orgs}
|
||||
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
|
||||
onDiscard={() => setVoiceResult(null)}
|
||||
|
||||
@@ -10,31 +10,44 @@ import {
|
||||
ResponsiveDialogPanel,
|
||||
} from '@/components/ui/shared/ResponsiveDialog';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '@/components/treatment/prosthesisTree';
|
||||
import {
|
||||
connectedTeethFromResult,
|
||||
countSelected,
|
||||
hasAnythingToApply,
|
||||
initialVoiceSelection,
|
||||
joblessProsthesisTargets,
|
||||
prosthesisChartData,
|
||||
prosthesisTargetLines,
|
||||
voiceRowAvailability,
|
||||
withChosenArch,
|
||||
withChosenProsthesisLeaf,
|
||||
withChosenTeeth,
|
||||
} from '@/components/treatment/voiceReviewRows';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
|
||||
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
||||
import type {
|
||||
VoiceApplySelection,
|
||||
VoiceExtractionResult,
|
||||
VoiceUnresolvedItem,
|
||||
} from '@/types/voice';
|
||||
|
||||
interface VoiceReviewSheetProps {
|
||||
result: VoiceExtractionResult;
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
prosthesisCatalog: ProsthesisCatalogEntry[];
|
||||
/** `prosthesis` today, but kept generic — the same set the workspace already tracks. */
|
||||
labDependentCodes: ReadonlySet<string>;
|
||||
labs: LinkedOrganizationOption[];
|
||||
/** The result is handed back because the sheet may have added teeth the model missed. */
|
||||
/** The result is handed back because the sheet may have added teeth or jobs the model missed. */
|
||||
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
type ArchPick = 'upper' | 'lower';
|
||||
|
||||
/**
|
||||
* Confirmation step between the model's output and the form.
|
||||
*
|
||||
@@ -45,50 +58,152 @@ export function VoiceReviewSheet({
|
||||
result,
|
||||
treatmentCatalog,
|
||||
prosthesisCatalog,
|
||||
labDependentCodes,
|
||||
labs,
|
||||
onApply,
|
||||
onDiscard,
|
||||
}: VoiceReviewSheetProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const locale = useLocale();
|
||||
const { formatDate } = useAppFormatters();
|
||||
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
|
||||
initialVoiceSelection(result),
|
||||
initialVoiceSelection(result, labDependentCodes),
|
||||
);
|
||||
const [chosen, setChosen] = useState<FdiToothId[]>([]);
|
||||
|
||||
// Everything below renders from `effective`, never from `result` — a tooth picked from
|
||||
// the candidate chips has to reach the rows, the chart and the apply count alike.
|
||||
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
|
||||
// Every kind of candidate chip the sheet can offer, tracked separately because each folds
|
||||
// into the result a different way (decision 50). Toggling off removes only the clinician's
|
||||
// own pick — nothing the recording already produced is ever un-added.
|
||||
const [chosenTeeth, setChosenTeeth] = useState<FdiToothId[]>([]);
|
||||
const [chosenAssignmentTeeth, setChosenAssignmentTeeth] = useState<Record<number, FdiToothId[]>>(
|
||||
{},
|
||||
);
|
||||
const [chosenArches, setChosenArches] = useState<Record<number, ArchPick[]>>({});
|
||||
const [chosenLeaves, setChosenLeaves] = useState<Record<number, string[]>>({});
|
||||
|
||||
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
|
||||
// Everything below renders from `effective`, never from `result` — a candidate picked from
|
||||
// the chips has to reach the rows, the chart and the apply count alike.
|
||||
const effective = useMemo(() => {
|
||||
let next = withChosenTeeth(result, chosenTeeth);
|
||||
for (const [index, teeth] of Object.entries(chosenAssignmentTeeth)) {
|
||||
next = withChosenTeeth(next, teeth, Number(index));
|
||||
}
|
||||
for (const [index, arches] of Object.entries(chosenArches)) {
|
||||
for (const arch of arches) next = withChosenArch(next, Number(index), arch);
|
||||
}
|
||||
for (const [index, leaves] of Object.entries(chosenLeaves)) {
|
||||
for (const leaf of leaves) next = withChosenProsthesisLeaf(next, Number(index), leaf);
|
||||
}
|
||||
return next;
|
||||
}, [result, chosenTeeth, chosenAssignmentTeeth, chosenArches, chosenLeaves]);
|
||||
|
||||
const available = useMemo(
|
||||
() => voiceRowAvailability(effective, labDependentCodes),
|
||||
[effective, labDependentCodes],
|
||||
);
|
||||
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
|
||||
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
|
||||
const nothingToApply = !hasAnythingToApply(effective);
|
||||
const selectedCount = countSelected(selection, available);
|
||||
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],
|
||||
);
|
||||
// Decision 41: prosthesis jobs exist on one treatment type only, so a ticked prosthesis row
|
||||
// pins the type row. Locking it is what makes "restoration detail carrying lab rows"
|
||||
// unreachable rather than merely discouraged.
|
||||
const typeForcedByProsthesis =
|
||||
available.prosthesis && selection.prosthesis && effective.treatmentType != null;
|
||||
|
||||
const pickCandidate = (tooth: FdiToothId) => {
|
||||
const nextChosen = chosen.includes(tooth)
|
||||
? chosen.filter((t) => t !== tooth)
|
||||
: [...chosen, tooth];
|
||||
setChosen(nextChosen);
|
||||
setSelection((prev) => ({
|
||||
...prev,
|
||||
// The teeth row starts unticked whenever the recording produced no teeth of its own,
|
||||
// and a picked tooth that is not ticked applies nothing.
|
||||
teeth: true,
|
||||
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
|
||||
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
|
||||
// clinician's call.
|
||||
prosthesis:
|
||||
prev.prosthesis &&
|
||||
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
|
||||
}));
|
||||
// What Apply actually sends. The locked type row is ticked on screen, so it has to be
|
||||
// ticked in the count and in the payload alike — counting the raw `selection` showed
|
||||
// "Apply 1" while two fields landed.
|
||||
const effectiveSelection: VoiceApplySelection = typeForcedByProsthesis
|
||||
? { ...selection, treatmentType: true }
|
||||
: selection;
|
||||
|
||||
const nothingToApply = !hasAnythingToApply(effective, labDependentCodes);
|
||||
const selectedCount = countSelected(effectiveSelection, available);
|
||||
|
||||
const targetLabel = (target: string): string => {
|
||||
if (target === ARCH_TOOTH_UPPER) return t('upperArch');
|
||||
if (target === ARCH_TOOTH_LOWER) return t('lowerArch');
|
||||
return target;
|
||||
};
|
||||
|
||||
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
|
||||
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
|
||||
|
||||
const isPicked = (item: VoiceUnresolvedItem, code: string): boolean => {
|
||||
const index = item.assignmentIndex;
|
||||
if (item.reason === 'arch_not_spoken') {
|
||||
return index != null && (chosenArches[index] ?? []).includes(code as ArchPick);
|
||||
}
|
||||
if (item.reason === 'prosthesis_type_ambiguous') {
|
||||
return index != null && (chosenLeaves[index] ?? []).includes(code);
|
||||
}
|
||||
if (index != null) return (chosenAssignmentTeeth[index] ?? []).includes(code as FdiToothId);
|
||||
return chosenTeeth.includes(code as FdiToothId);
|
||||
};
|
||||
|
||||
/**
|
||||
* A picked candidate has no meaning unless its own row is ticked, so each branch ticks the
|
||||
* row it feeds. Never derive that from `available`: it is memoised from `effective`, which
|
||||
* this handler is in the middle of changing, so it still reports the row as unavailable and
|
||||
* the pick would apply nothing.
|
||||
*/
|
||||
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],
|
||||
};
|
||||
});
|
||||
setSelection((prev) => ({ ...prev, prosthesis: true }));
|
||||
return;
|
||||
}
|
||||
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] };
|
||||
});
|
||||
setSelection((prev) => ({ ...prev, prosthesis: true }));
|
||||
return;
|
||||
}
|
||||
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],
|
||||
};
|
||||
});
|
||||
setSelection((prev) => ({ ...prev, prosthesis: true }));
|
||||
return;
|
||||
}
|
||||
const tooth = code as FdiToothId;
|
||||
setChosenTeeth((prev) => (prev.includes(tooth) ? prev.filter((t2) => t2 !== tooth) : [...prev, tooth]));
|
||||
setSelection((prev) => ({ ...prev, teeth: true }));
|
||||
};
|
||||
|
||||
const candidateLabel = (item: VoiceUnresolvedItem, code: string): string => {
|
||||
if (item.reason === 'arch_not_spoken') return code === 'upper' ? t('upperArch') : t('lowerArch');
|
||||
if (item.reason === 'prosthesis_type_ambiguous') return labelFor(code, prosthesisCatalog);
|
||||
return code;
|
||||
};
|
||||
|
||||
const promptFor = (item: VoiceUnresolvedItem): string | null => {
|
||||
if (item.reason === 'arch_not_spoken') return t('voicePickJaw');
|
||||
if (item.reason === 'tooth_missing_quadrant') return t('voicePickTooth');
|
||||
return null;
|
||||
};
|
||||
|
||||
const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
|
||||
setSelection((prev) => ({ ...prev, [key]: checked }));
|
||||
|
||||
@@ -104,10 +219,6 @@ export function VoiceReviewSheet({
|
||||
{t('voiceReviewTitle')}
|
||||
</h2>
|
||||
|
||||
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
|
||||
{effective.transcript}
|
||||
</p>
|
||||
|
||||
{nothingToApply ? (
|
||||
<p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p>
|
||||
) : (
|
||||
@@ -115,8 +226,10 @@ export function VoiceReviewSheet({
|
||||
{available.treatmentType ? (
|
||||
<Row
|
||||
label={t('treatmentType')}
|
||||
checked={selection.treatmentType}
|
||||
checked={selection.treatmentType || typeForcedByProsthesis}
|
||||
onChange={toggle('treatmentType')}
|
||||
locked={typeForcedByProsthesis}
|
||||
warning={typeForcedByProsthesis ? t('voiceTypeForcedByProsthesis') : undefined}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{labelFor(effective.treatmentType, treatmentCatalog)}
|
||||
@@ -138,6 +251,59 @@ export function VoiceReviewSheet({
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.prosthesis ? (
|
||||
<Row
|
||||
label={t('voiceTeethAndProsthesis')}
|
||||
checked={selection.prosthesis}
|
||||
onChange={toggle('prosthesis')}
|
||||
>
|
||||
<div className="mt-1">
|
||||
<FdiToothChart
|
||||
readOnly
|
||||
compact
|
||||
scale={0.55}
|
||||
selected={chartData.selectedTeeth}
|
||||
crownColors={chartData.crownColors}
|
||||
rootColors={chartData.rootColors}
|
||||
archHighlight={chartData.archHighlight}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-primary">
|
||||
{prosthesisLines.map((line, i) => (
|
||||
<span key={`line-${line.target}`}>
|
||||
{i > 0 ? ' · ' : ''}
|
||||
{targetLabel(line.target)}:{' '}
|
||||
{line.applied.map((code, j) => (
|
||||
<span key={code}>
|
||||
{j > 0 ? ' + ' : ''}
|
||||
{labelFor(code, prosthesisCatalog)}
|
||||
</span>
|
||||
))}
|
||||
{line.refused.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
title={t('voiceStackRefused')}
|
||||
className="text-text-muted line-through"
|
||||
>
|
||||
{' + '}
|
||||
{labelFor(code, prosthesisCatalog)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
{joblessTargets.map((target, i) => (
|
||||
<span
|
||||
key={`jobless-${target}`}
|
||||
className="text-text-muted line-through"
|
||||
>
|
||||
{prosthesisLines.length > 0 || i > 0 ? ' · ' : ''}
|
||||
{targetLabel(target)}: {t('voiceNoProsthesisHeard')}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.comment ? (
|
||||
<Row
|
||||
label={t('comments')}
|
||||
@@ -150,29 +316,6 @@ export function VoiceReviewSheet({
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.prosthesis && effective.prosthesis ? (
|
||||
<Row
|
||||
label={t('prosthesisColType')}
|
||||
checked={selection.prosthesis}
|
||||
onChange={toggle('prosthesis')}
|
||||
warning={
|
||||
effective.prosthesis.complete
|
||||
? undefined
|
||||
: t('voiceProsthesisIncomplete', {
|
||||
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-text-primary">
|
||||
{Object.entries(effective.prosthesis.byTooth)
|
||||
.map(
|
||||
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
|
||||
)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{available.lab ? (
|
||||
<Row
|
||||
label={t('entryStepLab')}
|
||||
@@ -212,23 +355,29 @@ export function VoiceReviewSheet({
|
||||
{t(`voiceUnresolved.${item.reason}`)}
|
||||
{item.candidates && item.candidates.length > 0 ? (
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-text-muted">{t('voicePickTooth')}</span>
|
||||
{item.candidates.map((tooth) => {
|
||||
const picked = chosen.includes(tooth as FdiToothId);
|
||||
{promptFor(item) ? (
|
||||
<span className="text-text-muted">{promptFor(item)}</span>
|
||||
) : null}
|
||||
{item.candidates.map((code) => {
|
||||
const picked = isPicked(item, code);
|
||||
return (
|
||||
<button
|
||||
key={tooth}
|
||||
key={code}
|
||||
type="button"
|
||||
aria-pressed={picked}
|
||||
aria-label={t('toothAria', { fdi: tooth })}
|
||||
onClick={() => pickCandidate(tooth as FdiToothId)}
|
||||
aria-label={
|
||||
item.reason === 'tooth_missing_quadrant'
|
||||
? t('toothAria', { fdi: code })
|
||||
: candidateLabel(item, code)
|
||||
}
|
||||
onClick={() => pickCandidate(item, code)}
|
||||
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
|
||||
picked
|
||||
? 'border-transparent bg-primary text-white'
|
||||
: 'border-border text-text-primary hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
{tooth}
|
||||
{candidateLabel(item, code)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -248,7 +397,7 @@ export function VoiceReviewSheet({
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={() => onApply(selection, effective)}
|
||||
onClick={() => onApply(effectiveSelection, effective)}
|
||||
fullWidth
|
||||
className="sm:w-auto"
|
||||
>
|
||||
@@ -265,17 +414,20 @@ function Row({
|
||||
checked,
|
||||
onChange,
|
||||
warning,
|
||||
locked,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
warning?: string;
|
||||
/** Ticked and not untickable — the value is implied by another row (decision 41). */
|
||||
locked?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
|
||||
<Checkbox checked={checked} onChange={onChange} label={label} />
|
||||
<Checkbox checked={checked} onChange={onChange} label={label} disabled={locked} />
|
||||
<div className="mt-1 ps-7 min-w-0">{children}</div>
|
||||
{warning ? (
|
||||
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
@@ -295,12 +447,3 @@ function civilDateToLocalDate(iso: string): Date {
|
||||
const [year, month, day] = iso.split('-').map(Number);
|
||||
return new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
}
|
||||
|
||||
/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */
|
||||
function formatToothList(teeth: readonly string[], locale: string): string {
|
||||
try {
|
||||
return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]);
|
||||
} catch {
|
||||
return teeth.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,13 @@ export function pickRecordingMimeType(): string | null {
|
||||
for (const type of PREFERRED_MIME_TYPES) {
|
||||
if (MediaRecorder.isTypeSupported(type)) return type;
|
||||
}
|
||||
return null;
|
||||
// None of the preferred containers passed `isTypeSupported` — a Safari version whose check
|
||||
// exists but answers false for a container it can still record (e.g. plain `audio/mp4`).
|
||||
// The preference list is not a requirement: fall back to the "let the browser choose" hint
|
||||
// rather than refusing outright. `onstop` derives the real container from
|
||||
// `recorder.mimeType`, so this is only wrong when the browser genuinely cannot record at
|
||||
// all — and `new MediaRecorder()` / `recorder.start()` throwing is handled at the call site.
|
||||
return '';
|
||||
}
|
||||
|
||||
/** `audio/webm;codecs=opus` → `webm`, which is what the API's `format` field wants. */
|
||||
|
||||
@@ -6,10 +6,12 @@ export type VoiceUnresolvedReason =
|
||||
| 'not_permanent_tooth'
|
||||
| 'position_out_of_range'
|
||||
| 'tooth_missing_quadrant'
|
||||
| 'prosthesis_type_ambiguous'
|
||||
| 'arch_not_spoken'
|
||||
| 'code_not_valid_for_target'
|
||||
| 'malformed'
|
||||
| 'span_not_same_arch'
|
||||
| 'unknown_catalog_code'
|
||||
| 'tooth_not_selected'
|
||||
| 'invalid_date';
|
||||
|
||||
export interface VoiceUnresolvedItem {
|
||||
@@ -17,26 +19,42 @@ export interface VoiceUnresolvedItem {
|
||||
spoken: string;
|
||||
reason: VoiceUnresolvedReason;
|
||||
/**
|
||||
* FDI codes still consistent with what was heard, when a choice would settle it — the
|
||||
* review sheet offers them as chips. Only `tooth_missing_quadrant` carries these.
|
||||
* Values still consistent with what was heard, when a choice would settle it — the review
|
||||
* sheet offers them as chips. FDI codes for `tooth_missing_quadrant`, leaf codes for
|
||||
* `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`.
|
||||
*/
|
||||
candidates?: string[];
|
||||
/**
|
||||
* Set only when this item came from resolving a `prosthesisAssignments` entry. A picked
|
||||
* chip then inherits that assignment's `types` (or supplies the missing leaf to it) instead
|
||||
* of resolving to a jobless tooth.
|
||||
*/
|
||||
assignmentIndex?: number;
|
||||
}
|
||||
|
||||
export interface VoiceProsthesisResult {
|
||||
byTooth: Record<string, string>;
|
||||
/** False means the case cannot ship — every tooth needs a prosthesis type. */
|
||||
complete: boolean;
|
||||
missingTeeth: FdiToothId[];
|
||||
/**
|
||||
* One spoken instruction, resolved: these targets — FDI codes, or `'UA'`/`'LA'` jaw sentinels
|
||||
* (`ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER` in `prosthesisTree.ts`) — get these leaf codes. Empty
|
||||
* `types` means the target was named with no job at all (struck through in the sheet); empty
|
||||
* `targets` with a `prosthesis_type_ambiguous` unresolved item at this index means the target
|
||||
* is pending a material pick.
|
||||
*/
|
||||
export interface VoiceProsthesisAssignment {
|
||||
targets: string[];
|
||||
types: string[];
|
||||
spoken: string;
|
||||
}
|
||||
|
||||
export interface VoiceExtractionResult {
|
||||
transcript: string;
|
||||
/**
|
||||
* No transcript. A raw dictation can carry the patient's spoken name, so the server never
|
||||
* sends it — it is logged there instead (spec §10).
|
||||
*/
|
||||
treatmentType: string | null;
|
||||
teeth: FdiToothId[];
|
||||
toothSelectionGroups: ToothSelectionGroup[];
|
||||
comment: string | null;
|
||||
prosthesis: VoiceProsthesisResult | null;
|
||||
prosthesisAssignments: VoiceProsthesisAssignment[];
|
||||
labId: string | null;
|
||||
/** When false, the lab row must not tick itself — the name only approximately matched. */
|
||||
labMatchExact: boolean;
|
||||
|
||||
15
frontend/vitest.config.ts
Normal file
15
frontend/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user