feat(voice): adapt voice entry to the stacked-jobs prosthesis model
Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,18 @@ const LOCALE_NOTES: Record<string, string> = {
|
||||
'("دندون شماره ۲۶"). Digits may arrive in Persian or Latin script — either way, copy',
|
||||
'the number into "fdi" as two Latin digits. The descriptive form is quadrant-relative:',
|
||||
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
|
||||
'A jaw is spoken as "فک بالا" (upper jaw) or "فک پایین" (lower jaw), sometimes just',
|
||||
'"بالا"/"پایین" in context, or "هر دو فک" (both jaws).',
|
||||
].join(' '),
|
||||
nl: [
|
||||
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
|
||||
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.',
|
||||
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six. A jaw is',
|
||||
'"bovenkaak" (upper) or "onderkaak" (lower), or "beide kaken" (both).',
|
||||
].join(' '),
|
||||
en: [
|
||||
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
|
||||
'all tooth 26. The descriptive form is "upper right six".',
|
||||
'all tooth 26. The descriptive form is "upper right six". A jaw is "upper jaw"/"lower',
|
||||
'jaw", or "both jaws".',
|
||||
].join(' '),
|
||||
};
|
||||
|
||||
@@ -24,6 +28,63 @@ function codeList(entries: { code: string; label: string }[]): string {
|
||||
return entries.map((e) => `- ${e.code} = ${e.label}`).join('\n');
|
||||
}
|
||||
|
||||
/** "per-tooth" | "jaw" | "per-tooth or jaw" — derived from the leaves' own chartRegion, never hardcoded. */
|
||||
function regionNote(regions: ReadonlySet<string>): string {
|
||||
const isJaw = regions.has('arch');
|
||||
const isTooth = regions.has('crown') || regions.has('root');
|
||||
if (isJaw && isTooth)
|
||||
return 'per-tooth or jaw, depending on the specific code';
|
||||
return isJaw ? 'jaw' : 'per-tooth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the prosthesis catalog as the tree it is: category -> (subcategory ->) leaves.
|
||||
* Nothing here is hardcoded — every code, label and region annotation comes from the data
|
||||
* `buildCatalog` fetched, so a catalog change needs no prompt change.
|
||||
*/
|
||||
function prosthesisTree(catalog: ExtractionCatalog): string {
|
||||
const byCategory = new Map<string, ExtractionCatalog['prosthesisTypes']>();
|
||||
for (const leaf of catalog.prosthesisTypes) {
|
||||
const list = byCategory.get(leaf.category) ?? [];
|
||||
list.push(leaf);
|
||||
byCategory.set(leaf.category, list);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const category of catalog.prosthesisCategories) {
|
||||
const leaves = byCategory.get(category.code) ?? [];
|
||||
const regions = new Set(leaves.map((l) => l.chartRegion));
|
||||
lines.push(
|
||||
`CATEGORY ${category.code} = ${category.label} (${regionNote(regions)})`,
|
||||
);
|
||||
|
||||
const bySubcategory = new Map<string, typeof leaves>();
|
||||
const bare: typeof leaves = [];
|
||||
for (const leaf of leaves) {
|
||||
if (leaf.subcategory) {
|
||||
const list = bySubcategory.get(leaf.subcategory) ?? [];
|
||||
list.push(leaf);
|
||||
bySubcategory.set(leaf.subcategory, list);
|
||||
} else {
|
||||
bare.push(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
for (const leaf of bare) {
|
||||
lines.push(` - ${leaf.code} = ${leaf.label}`);
|
||||
}
|
||||
for (const sub of catalog.prosthesisSubcategories) {
|
||||
const subLeaves = bySubcategory.get(sub.code);
|
||||
if (!subLeaves || subLeaves.length === 0) continue;
|
||||
lines.push(` SUBCATEGORY ${sub.code} = ${sub.label}`);
|
||||
for (const leaf of subLeaves) {
|
||||
lines.push(` - ${leaf.code} = ${leaf.label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildExtractionPrompt(
|
||||
transcript: string,
|
||||
catalog: ExtractionCatalog,
|
||||
@@ -36,15 +97,15 @@ export function buildExtractionPrompt(
|
||||
'You are a parser, not an assistant: report only what was said.',
|
||||
'',
|
||||
'HARD RULES',
|
||||
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
|
||||
' must be codes from the lists below. labId must be an id from the lab list. If what you',
|
||||
' heard is not in a list, use null.',
|
||||
'1. Never invent a code. treatmentType and prosthesis[].types[] must be codes from the',
|
||||
' lists below. labId must be an id from the lab list. If what you heard is not in a',
|
||||
' list, use null (or omit it from prosthesis[].types[]).',
|
||||
'2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
|
||||
" flip to the viewer's point of view.",
|
||||
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
|
||||
' If no deadline was mentioned, use due.kind = "none".',
|
||||
'4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
|
||||
' what was heard.',
|
||||
'4. Copy the exact spoken words for each tooth, jaw and prosthesis instruction into',
|
||||
' "spoken", so the clinician can see what was heard.',
|
||||
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
|
||||
' one is not.',
|
||||
'',
|
||||
@@ -71,8 +132,24 @@ export function buildExtractionPrompt(
|
||||
'TREATMENT TYPE CODES',
|
||||
codeList(catalog.treatmentTypes),
|
||||
'',
|
||||
'PROSTHESIS TYPE CODES',
|
||||
codeList(catalog.prosthesisTypes),
|
||||
'PROSTHESIS WORK — prosthesis[]',
|
||||
'One entry per spoken instruction: "these targets get these jobs". Each entry is',
|
||||
'{ targets, types, spoken }:',
|
||||
'- targets: the teeth OR jaw(s) this instruction is for. A jaw target is a tooth object',
|
||||
' with "arch" set ("upper", "lower", or "both" for both jaws) and "position" left null —',
|
||||
' never invent a tooth number for a jaw-level appliance. A tooth target is the normal',
|
||||
' fdi / arch+side+position shape above.',
|
||||
'- types: one or more codes from the tree below, applied to every target in this entry.',
|
||||
' More than one code means a STACK on the same tooth — e.g. an abutment plus a crown on',
|
||||
' one implant site: types: ["zirconia_abutment", "monolithic_zirconia"].',
|
||||
' If only the general term was said ("روکش", "veneer") and not a specific material, use',
|
||||
' the CATEGORY or SUBCATEGORY code instead of guessing a leaf.',
|
||||
'- A tooth named with no job at all still belongs in the top-level "teeth" list, not here.',
|
||||
'- If several teeth share one job, list them all as targets in one entry rather than',
|
||||
' repeating the entry — "12 and 13, PFM crown on both" -> one entry, two targets.',
|
||||
'',
|
||||
'PROSTHESIS TYPE CODES (tree — CATEGORY and SUBCATEGORY are marked; the rest are leaves)',
|
||||
prosthesisTree(catalog),
|
||||
'',
|
||||
'LABS THIS CLINIC CAN SEND TO',
|
||||
catalog.labs.length > 0
|
||||
@@ -82,7 +159,7 @@ export function buildExtractionPrompt(
|
||||
'OTHER FIELDS',
|
||||
'- connectedSpans: only for bridges or splinted units. Endpoints inclusive.',
|
||||
'- comment: clinical notes, in the language spoken. Omit the parts already captured as',
|
||||
' treatment type, teeth or deadline.',
|
||||
' treatment type, teeth, prosthesis work or deadline.',
|
||||
'- labMatchExact: true only when the spoken name matched a lab name exactly.',
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER } from '../../common/fdi';
|
||||
import {
|
||||
PROSTHESIS_TYPES,
|
||||
PROSTHESIS_CATEGORY_LABELS,
|
||||
PROSTHESIS_SUBCATEGORY_LABELS,
|
||||
} from '../../../prisma/catalog-seed-data';
|
||||
import {
|
||||
resolveConnectedSpans,
|
||||
resolveProsthesis,
|
||||
resolveProsthesisAssignment,
|
||||
resolveVoiceIntent,
|
||||
type ProsthesisLeaf,
|
||||
type ResolveContext,
|
||||
} from './extraction.resolver';
|
||||
import type { ToothIntent, VoiceIntent } from './voice.types';
|
||||
import type {
|
||||
ProsthesisAssignment,
|
||||
ToothIntent,
|
||||
UnresolvedItem,
|
||||
VoiceIntent,
|
||||
} from './voice.types';
|
||||
|
||||
const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
||||
kind: 'explicit',
|
||||
@@ -12,12 +24,75 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
||||
spoken,
|
||||
});
|
||||
|
||||
const positional = (
|
||||
overrides: Partial<Extract<ToothIntent, { kind: 'positional' }>> = {},
|
||||
): ToothIntent => ({
|
||||
kind: 'positional',
|
||||
arch: undefined as never,
|
||||
side: undefined as never,
|
||||
position: Number.NaN,
|
||||
spoken: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const PROSTHESIS_LEAVES: ProsthesisLeaf[] = [
|
||||
{
|
||||
code: 'pfm_crown',
|
||||
category: 'crown',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
},
|
||||
{
|
||||
code: 'monolithic_zirconia',
|
||||
category: 'crown',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
},
|
||||
{
|
||||
code: 'zirconia_abutment',
|
||||
category: 'implant',
|
||||
subcategory: '',
|
||||
chartRegion: 'root',
|
||||
},
|
||||
{
|
||||
code: 'screw_retained',
|
||||
category: 'implant',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
},
|
||||
{
|
||||
code: 'night_guard_soft',
|
||||
category: 'appliance',
|
||||
subcategory: 'night_guard',
|
||||
chartRegion: 'arch',
|
||||
},
|
||||
{
|
||||
code: 'complete_denture',
|
||||
category: 'removable',
|
||||
subcategory: '',
|
||||
chartRegion: 'arch',
|
||||
},
|
||||
{
|
||||
code: 'partial_denture',
|
||||
category: 'removable',
|
||||
subcategory: '',
|
||||
chartRegion: 'crown',
|
||||
},
|
||||
];
|
||||
|
||||
const CTX: ResolveContext = {
|
||||
todayIso: '2025-10-11',
|
||||
weekStartJs: 6, // Saturday — the fa week
|
||||
|
||||
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
|
||||
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
|
||||
prosthesisLeaves: PROSTHESIS_LEAVES,
|
||||
prosthesisCategoryCodes: new Set([
|
||||
'crown',
|
||||
'implant',
|
||||
'removable',
|
||||
'appliance',
|
||||
]),
|
||||
prosthesisSubcategoryCodes: new Set(['night_guard']),
|
||||
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
|
||||
};
|
||||
|
||||
@@ -111,122 +186,256 @@ describe('resolveConnectedSpans', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProsthesis', () => {
|
||||
const allowed = CTX.prosthesisTypeCodes;
|
||||
|
||||
it('expands the default across every tooth', () => {
|
||||
const result = resolveProsthesis(
|
||||
{ defaultType: 'monolithic_zirconia', overrides: [] },
|
||||
['14', '15'],
|
||||
allowed,
|
||||
describe('resolveProsthesisAssignment', () => {
|
||||
function resolve(assignment: ProsthesisAssignment, index = 0) {
|
||||
const unresolved: UnresolvedItem[] = [];
|
||||
const resolved = resolveProsthesisAssignment(
|
||||
assignment,
|
||||
index,
|
||||
CTX,
|
||||
unresolved,
|
||||
);
|
||||
expect(result.prosthesis?.byTooth).toEqual({
|
||||
'14': 'monolithic_zirconia',
|
||||
'15': 'monolithic_zirconia',
|
||||
return { resolved, unresolved };
|
||||
}
|
||||
|
||||
it('resolves explicit tooth targets to FDI codes', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [tooth('12'), tooth('13')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'روکش پیافام برای ۱۲ و ۱۳',
|
||||
});
|
||||
expect(result.prosthesis?.complete).toBe(true);
|
||||
expect(resolved.targets.sort()).toEqual(['12', '13']);
|
||||
expect(resolved.types).toEqual(['pfm_crown']);
|
||||
});
|
||||
|
||||
it('applies per-tooth overrides on top of the default', () => {
|
||||
const result = resolveProsthesis(
|
||||
{
|
||||
defaultType: 'monolithic_zirconia',
|
||||
overrides: [{ tooth: tooth('26'), type: 'pfm_crown' }],
|
||||
},
|
||||
['14', '26'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.prosthesis?.byTooth).toEqual({
|
||||
'14': 'monolithic_zirconia',
|
||||
'26': 'pfm_crown',
|
||||
it('resolves a stack — more than one type on the same target', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['zirconia_abutment', 'monolithic_zirconia'],
|
||||
spoken: 'ایمپلنت با روکش زیرکونیا روی ۱۲',
|
||||
});
|
||||
expect(result.prosthesis?.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the map incomplete when a tooth ends up untyped', () => {
|
||||
// Unshippable: assertCompleteToothProsthesisMap would reject this at dispatch.
|
||||
const result = resolveProsthesis(
|
||||
{
|
||||
defaultType: null,
|
||||
overrides: [{ tooth: tooth('14'), type: 'pfm_crown' }],
|
||||
},
|
||||
['14', '15'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.prosthesis?.complete).toBe(false);
|
||||
expect(result.prosthesis?.missingTeeth).toEqual(['15']);
|
||||
});
|
||||
|
||||
it('rejects a catalog code the clinic does not have', () => {
|
||||
const result = resolveProsthesis(
|
||||
{ defaultType: 'gold_foil', overrides: [] },
|
||||
['14'],
|
||||
allowed,
|
||||
);
|
||||
// Nothing usable was said, so there is no prosthesis to show — not an empty one.
|
||||
expect(result.prosthesis).toBeNull();
|
||||
expect(result.unresolved).toEqual([
|
||||
{ spoken: 'gold_foil', reason: 'unknown_catalog_code' },
|
||||
expect(resolved.targets).toEqual(['12']);
|
||||
expect(resolved.types.sort()).toEqual([
|
||||
'monolithic_zirconia',
|
||||
'zirconia_abutment',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an override for a tooth that is not selected', () => {
|
||||
const result = resolveProsthesis(
|
||||
{
|
||||
defaultType: 'monolithic_zirconia',
|
||||
overrides: [{ tooth: tooth('37', 'سی و هفت'), type: 'pfm_crown' }],
|
||||
},
|
||||
['14'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.prosthesis?.byTooth).toEqual({ '14': 'monolithic_zirconia' });
|
||||
expect(result.unresolved).toEqual([
|
||||
{ spoken: 'سی و هفت', reason: 'tooth_not_selected' },
|
||||
]);
|
||||
it('resolves an arch target with no position to the jaw sentinel', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [positional({ arch: 'upper' })],
|
||||
types: ['night_guard_soft'],
|
||||
spoken: 'نایت گارد فک بالا',
|
||||
});
|
||||
expect(resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
|
||||
});
|
||||
|
||||
it('reports no prosthesis at all when the object carries nothing usable', () => {
|
||||
// An empty-but-present map would paint a plain restoration with a fabricated
|
||||
// "incomplete, cannot ship" warning.
|
||||
for (const empty of [{ defaultType: null, overrides: [] }, {} as never]) {
|
||||
expect(
|
||||
resolveProsthesis(empty, ['14', '15'], allowed).prosthesis,
|
||||
).toBeNull();
|
||||
it('resolves "both jaws" to both sentinels', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [positional({ arch: 'both' })],
|
||||
types: ['night_guard_soft'],
|
||||
spoken: 'نایت گارد هر دو فک',
|
||||
});
|
||||
expect(resolved.targets.sort()).toEqual(
|
||||
[ARCH_TOOTH_LOWER, ARCH_TOOTH_UPPER].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies a leaf, a category and a subcategory code independently', () => {
|
||||
const leaf = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: '',
|
||||
});
|
||||
expect(leaf.resolved.types).toEqual(['pfm_crown']);
|
||||
expect(leaf.unresolved).toEqual([]);
|
||||
|
||||
const category = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['crown'],
|
||||
spoken: 'روکش',
|
||||
});
|
||||
expect(category.resolved.types).toEqual([]);
|
||||
expect(category.unresolved).toEqual([
|
||||
{
|
||||
spoken: 'روکش',
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: ['monolithic_zirconia', 'pfm_crown'],
|
||||
assignmentIndex: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const subcategory = resolve({
|
||||
targets: [positional({ arch: 'upper' })],
|
||||
types: ['night_guard'],
|
||||
spoken: 'نایت گارد',
|
||||
});
|
||||
expect(subcategory.resolved.types).toEqual([]);
|
||||
expect(subcategory.unresolved[0]).toMatchObject({
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: ['night_guard_soft'],
|
||||
});
|
||||
});
|
||||
|
||||
it('asserts the leaf, category and subcategory namespaces are disjoint on the live catalog', () => {
|
||||
const leafCodes = new Set(PROSTHESIS_TYPES.map((t) => t.code));
|
||||
const categoryCodes = new Set(PROSTHESIS_TYPES.map((t) => t.category));
|
||||
const subcategoryCodes = new Set(
|
||||
PROSTHESIS_TYPES.map((t) => t.subcategory).filter(Boolean),
|
||||
);
|
||||
for (const code of categoryCodes) expect(leafCodes.has(code)).toBe(false);
|
||||
for (const code of subcategoryCodes) {
|
||||
expect(leafCodes.has(code)).toBe(false);
|
||||
expect(categoryCodes.has(code)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports no prosthesis when there are no teeth to type', () => {
|
||||
const result = resolveProsthesis(
|
||||
{ defaultType: 'monolithic_zirconia', overrides: [] },
|
||||
[],
|
||||
allowed,
|
||||
);
|
||||
expect(result.prosthesis).toBeNull();
|
||||
});
|
||||
|
||||
it('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('defers the region check for a mixed-region category (removable)', () => {
|
||||
// complete_denture is 'arch', partial_denture is 'crown' — no region to check until a
|
||||
// leaf is picked. Neither a tooth nor a jaw target may be rejected while it is still
|
||||
// the bare category.
|
||||
const toothTarget = resolve({
|
||||
targets: [tooth('12')],
|
||||
types: ['removable'],
|
||||
spoken: 'دنچر برای ۱۲',
|
||||
});
|
||||
expect(toothTarget.resolved.targets).toEqual(['12']);
|
||||
expect(toothTarget.unresolved).toContainEqual(
|
||||
expect.objectContaining({ reason: 'prosthesis_type_ambiguous' }),
|
||||
);
|
||||
expect(toothTarget.unresolved).not.toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
|
||||
const jawTarget = resolve({
|
||||
targets: [positional({ arch: 'upper' })],
|
||||
types: ['removable'],
|
||||
spoken: 'دنچر فک بالا',
|
||||
});
|
||||
expect(jawTarget.resolved.targets).toEqual([ARCH_TOOTH_UPPER]);
|
||||
expect(jawTarget.unresolved).not.toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('a target with no types resolves with an empty types[], and does not fail the assignment', () => {
|
||||
const { resolved } = resolve({
|
||||
targets: [tooth('13'), tooth('14')],
|
||||
types: [],
|
||||
spoken: '۱۳ و ۱۴',
|
||||
});
|
||||
// Nothing to validate a region against yet, so a bare target still resolves as valid
|
||||
// geometry — the frontend renders it struck through ("no prosthesis heard") because
|
||||
// `types` is empty and no `prosthesis_type_ambiguous` item references this assignment.
|
||||
expect(resolved.targets.sort()).toEqual(['13', '14']);
|
||||
expect(resolved.types).toEqual([]);
|
||||
});
|
||||
|
||||
it('one target failing validity does not exclude the rest of the assignment', () => {
|
||||
const { resolved, unresolved } = resolve({
|
||||
targets: [tooth('12'), positional({ arch: 'upper' })],
|
||||
types: ['pfm_crown'],
|
||||
spoken: '',
|
||||
});
|
||||
expect(resolved.targets).toEqual(['12']);
|
||||
expect(unresolved).toContainEqual(
|
||||
expect.objectContaining({ reason: 'code_not_valid_for_target' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('carries an assignment target that could not be resolved, with the arch candidates', () => {
|
||||
const { unresolved } = resolve({
|
||||
targets: [positional({ spoken: 'نایت گارد' })],
|
||||
types: ['night_guard_soft'],
|
||||
spoken: 'نایت گارد',
|
||||
});
|
||||
expect(unresolved).toContainEqual({
|
||||
spoken: 'نایت گارد',
|
||||
reason: 'arch_not_spoken',
|
||||
candidates: ['upper', 'lower'],
|
||||
assignmentIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the assignment index on a missing-quadrant target', () => {
|
||||
const { unresolved } = resolve({
|
||||
targets: [positional({ position: 2, spoken: 'دندون دو' })],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'دندون دو روکش',
|
||||
});
|
||||
expect(unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'tooth_missing_quadrant',
|
||||
assignmentIndex: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('every prosthesis category and subcategory has a non-empty label in fa/en/nl', () => {
|
||||
const locales = ['fa', 'en', 'nl'] as const;
|
||||
|
||||
it.each(Object.entries(PROSTHESIS_CATEGORY_LABELS))(
|
||||
'category %s',
|
||||
(_code, labels) => {
|
||||
for (const locale of locales) {
|
||||
expect(labels[locale]?.trim()).toBeTruthy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(Object.entries(PROSTHESIS_SUBCATEGORY_LABELS))(
|
||||
'subcategory %s',
|
||||
(_code, labels) => {
|
||||
for (const locale of locales) {
|
||||
expect(labels[locale]?.trim()).toBeTruthy();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('resolveVoiceIntent', () => {
|
||||
@@ -235,7 +444,7 @@ describe('resolveVoiceIntent', () => {
|
||||
teeth: [tooth('14'), tooth('15')],
|
||||
connectedSpans: [],
|
||||
comment: ' حساسیت به سرما ',
|
||||
prosthesis: null,
|
||||
prosthesis: [],
|
||||
labId: null,
|
||||
labMatchExact: false,
|
||||
due: null,
|
||||
@@ -246,7 +455,7 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.treatmentType).toBe('restoration');
|
||||
expect(result.teeth).toEqual(['14', '15']);
|
||||
expect(result.comment).toBe('حساسیت به سرما');
|
||||
expect(result.prosthesis).toBeNull();
|
||||
expect(result.prosthesisAssignments).toEqual([]);
|
||||
expect(result.unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -299,27 +508,38 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.labMatchExact).toBe(false);
|
||||
});
|
||||
|
||||
it('applies prosthesis over the span-expanded tooth set', () => {
|
||||
it('forces treatmentType to prosthesis when an assignment resolves', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
treatmentType: 'prosthesis',
|
||||
teeth: [tooth('14')],
|
||||
connectedSpans: [{ from: tooth('14'), to: tooth('16') }],
|
||||
prosthesis: { defaultType: 'monolithic_zirconia', overrides: [] },
|
||||
treatmentType: 'restoration',
|
||||
prosthesis: [
|
||||
{
|
||||
targets: [tooth('12')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: 'روکش برای ۱۲',
|
||||
},
|
||||
],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
// 15 was never spoken but is part of the bridge, so it must carry a type too.
|
||||
expect(result.teeth).toEqual(['14', '15', '16']);
|
||||
expect(result.prosthesis?.complete).toBe(true);
|
||||
expect(Object.keys(result.prosthesis!.byTooth).sort()).toEqual([
|
||||
'14',
|
||||
'15',
|
||||
'16',
|
||||
expect(result.treatmentType).toBe('prosthesis');
|
||||
expect(result.prosthesisAssignments).toEqual([
|
||||
{ targets: ['12'], types: ['pfm_crown'], spoken: 'روکش برای ۱۲' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not force prosthesis when every assignment resolved no target', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
prosthesis: [{ targets: [], types: ['pfm_crown'], spoken: '' }],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
expect(result.treatmentType).toBe('restoration');
|
||||
});
|
||||
|
||||
it('resolves a due date through the same context', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{ ...base, due: { kind: 'weekday', weekday: 'thursday', which: 'this' } },
|
||||
@@ -328,7 +548,7 @@ describe('resolveVoiceIntent', () => {
|
||||
expect(result.dueDate).toBe('2025-10-16');
|
||||
});
|
||||
|
||||
it('collects unresolved items from every stage', () => {
|
||||
it('collects unresolved items from every stage, and only assignment items carry an index', () => {
|
||||
const result = resolveVoiceIntent(
|
||||
{
|
||||
...base,
|
||||
@@ -336,16 +556,34 @@ describe('resolveVoiceIntent', () => {
|
||||
teeth: [tooth('51', 'شیری')],
|
||||
connectedSpans: [{ from: tooth('14'), to: tooth('44') }],
|
||||
due: { kind: 'jalali', jy: 1404, jm: 12, jd: 30 },
|
||||
prosthesis: [
|
||||
{
|
||||
targets: [tooth('99', 'نود و نه')],
|
||||
types: ['pfm_crown'],
|
||||
spoken: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
CTX,
|
||||
);
|
||||
const reasons = result.unresolved.map((u) => u.reason).sort();
|
||||
expect(reasons).toEqual([
|
||||
'invalid_date',
|
||||
'malformed',
|
||||
'not_permanent_tooth',
|
||||
'span_not_same_arch',
|
||||
'unknown_catalog_code',
|
||||
]);
|
||||
|
||||
const outsideAssignment = result.unresolved.filter(
|
||||
(u) => u.reason === 'not_permanent_tooth',
|
||||
);
|
||||
expect(outsideAssignment[0].assignmentIndex).toBeUndefined();
|
||||
|
||||
const insideAssignment = result.unresolved.filter(
|
||||
(u) => u.reason === 'malformed',
|
||||
);
|
||||
expect(insideAssignment[0].assignmentIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an empty comment as absent', () => {
|
||||
|
||||
@@ -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,73 +183,259 @@ export function resolveConnectedSpans(
|
||||
};
|
||||
}
|
||||
|
||||
// --- Prosthesis assignments -------------------------------------------------------------
|
||||
|
||||
type TargetResolution =
|
||||
| { kind: 'tooth'; fdi: string }
|
||||
| { kind: 'jaw'; arch: 'upper' | 'lower' | 'both' }
|
||||
| { kind: 'unresolved'; reason: UnresolvedReason; candidates?: string[] };
|
||||
|
||||
const QUADRANT_ARCHES: readonly Arch[] = ['upper', 'lower'];
|
||||
const QUADRANT_SIDES: readonly PatientSide[] = [
|
||||
'patient_right',
|
||||
'patient_left',
|
||||
];
|
||||
|
||||
/** The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. */
|
||||
function quadrantCandidatesForTarget(
|
||||
target: Extract<ToothIntent, { kind: 'positional' }>,
|
||||
): string[] {
|
||||
const arches =
|
||||
target.arch === 'upper' || target.arch === 'lower'
|
||||
? [target.arch]
|
||||
: QUADRANT_ARCHES;
|
||||
const sides =
|
||||
target.side === 'patient_right' || target.side === 'patient_left'
|
||||
? [target.side]
|
||||
: QUADRANT_SIDES;
|
||||
const codes: string[] = [];
|
||||
for (const arch of arches) {
|
||||
for (const side of sides) {
|
||||
const fdi = toFdi(arch, side, target.position);
|
||||
if (fdi) codes.push(fdi);
|
||||
}
|
||||
}
|
||||
return codes.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پیافام" is
|
||||
* 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 {
|
||||
const rawTypes = Array.isArray(assignment?.types) ? assignment.types : [];
|
||||
const leafCodes = new Set<string>();
|
||||
const regionSet = new Set<string>();
|
||||
|
||||
for (const rawCode of rawTypes) {
|
||||
const classification = classifyTypeCode(rawCode, ctx);
|
||||
if (classification.kind === 'leaf') {
|
||||
leafCodes.add(classification.code);
|
||||
regionSet.add(classification.chartRegion);
|
||||
} else if (
|
||||
classification.kind === 'category' ||
|
||||
classification.kind === 'subcategory'
|
||||
) {
|
||||
unresolved.push({
|
||||
spoken: spokenOf(assignment) || classification.code,
|
||||
reason: 'prosthesis_type_ambiguous',
|
||||
candidates: [...classification.leafCodes].sort(),
|
||||
assignmentIndex: index,
|
||||
});
|
||||
// A category whose leaves share one region is informative even before the leaf is
|
||||
// picked; a mixed one (only `removable` today) defers the region check entirely
|
||||
// (decision 49) rather than risk `code_not_valid_for_target` on a category that has
|
||||
// not been narrowed yet.
|
||||
if (classification.regions.size === 1) {
|
||||
for (const region of classification.regions) regionSet.add(region);
|
||||
}
|
||||
} else if (typeof rawCode === 'string' && rawCode.trim()) {
|
||||
unresolved.push({
|
||||
spoken: rawCode,
|
||||
reason: 'unknown_catalog_code',
|
||||
assignmentIndex: index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rawTargets = Array.isArray(assignment?.targets)
|
||||
? assignment.targets
|
||||
: [];
|
||||
for (const override of overrides) {
|
||||
const 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 targets = new Set<string>();
|
||||
|
||||
for (const rawTarget of rawTargets) {
|
||||
const resolution = resolveAssignmentTarget(rawTarget);
|
||||
const spoken = spokenOf(rawTarget);
|
||||
|
||||
if (resolution.kind === 'unresolved') {
|
||||
unresolved.push(
|
||||
resolution.candidates
|
||||
? {
|
||||
spoken,
|
||||
reason: resolution.reason,
|
||||
candidates: resolution.candidates,
|
||||
assignmentIndex: index,
|
||||
}
|
||||
: { 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' });
|
||||
|
||||
// Nothing to validate against yet — a bare category is deferred, and an empty types[]
|
||||
// means the target is simply named with no job (left to the caller to render struck
|
||||
// through, using `teeth` minus every assignment's resolved targets).
|
||||
const valid =
|
||||
regionSet.size === 0 ||
|
||||
[...regionSet].some((region) =>
|
||||
acceptableRegions(resolution.kind).has(region),
|
||||
);
|
||||
if (!valid) {
|
||||
unresolved.push({
|
||||
spoken,
|
||||
reason: 'code_not_valid_for_target',
|
||||
assignmentIndex: index,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!type) {
|
||||
unresolved.push({ spoken, reason: 'unknown_catalog_code' });
|
||||
continue;
|
||||
|
||||
if (resolution.kind === 'tooth') {
|
||||
targets.add(resolution.fdi);
|
||||
} else {
|
||||
for (const sentinel of archSentinels(resolution.arch))
|
||||
targets.add(sentinel);
|
||||
}
|
||||
byTooth[tooth] = type;
|
||||
}
|
||||
|
||||
// Nothing usable was said about prosthesis. Returning an empty-but-present map would
|
||||
// paint a plain restoration with a fabricated "incomplete, cannot ship" warning.
|
||||
if (Object.keys(byTooth).length === 0) {
|
||||
return { prosthesis: null, unresolved };
|
||||
}
|
||||
|
||||
const missingTeeth = teeth.filter((tooth) => !byTooth[tooth]);
|
||||
return {
|
||||
prosthesis: {
|
||||
byTooth,
|
||||
complete: missingTeeth.length === 0,
|
||||
missingTeeth,
|
||||
},
|
||||
unresolved,
|
||||
targets: [...targets],
|
||||
types: [...leafCodes],
|
||||
spoken: spokenOf(assignment),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,12 +466,18 @@ export function resolveVoiceIntent(
|
||||
});
|
||||
}
|
||||
|
||||
const prosthesisResult = resolveProsthesis(
|
||||
intent?.prosthesis,
|
||||
spanResult.teeth,
|
||||
ctx.prosthesisTypeCodes,
|
||||
const rawAssignments = Array.isArray(intent?.prosthesis)
|
||||
? intent.prosthesis
|
||||
: [];
|
||||
const prosthesisAssignments = rawAssignments.map((assignment, index) =>
|
||||
resolveProsthesisAssignment(assignment, index, ctx, unresolved),
|
||||
);
|
||||
|
||||
// `prosthesis` is the only labDependent treatment type — any assignment that actually
|
||||
// landed on a target forces it, and the sheet locks that row while it is ticked (decision 41).
|
||||
const hasResolvedAssignment = prosthesisAssignments.some(
|
||||
(a) => a.targets.length > 0,
|
||||
);
|
||||
unresolved.push(...prosthesisResult.unresolved);
|
||||
|
||||
const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
|
||||
if (due.unresolved) unresolved.push(due.unresolved);
|
||||
@@ -287,11 +497,11 @@ export function resolveVoiceIntent(
|
||||
}
|
||||
|
||||
return {
|
||||
treatmentType,
|
||||
treatmentType: hasResolvedAssignment ? 'prosthesis' : treatmentType,
|
||||
teeth: spanResult.teeth,
|
||||
toothSelectionGroups: spanResult.groups,
|
||||
comment,
|
||||
prosthesis: prosthesisResult.prosthesis,
|
||||
prosthesisAssignments,
|
||||
labId,
|
||||
labMatchExact: labId ? intent?.labMatchExact === true : false,
|
||||
dueDate: due.dueDate,
|
||||
|
||||
@@ -23,8 +23,7 @@ const wire = (overrides: Partial<WireVoiceIntent> = {}): WireVoiceIntent => ({
|
||||
teeth: [],
|
||||
connectedSpans: [],
|
||||
comment: null,
|
||||
prosthesisDefaultType: null,
|
||||
prosthesisOverrides: [],
|
||||
prosthesis: [],
|
||||
labId: null,
|
||||
labMatchExact: false,
|
||||
due: emptyDue,
|
||||
@@ -177,26 +176,70 @@ describe('toVoiceIntent', () => {
|
||||
expect(result.due).toEqual({ kind: 'lunar_month' });
|
||||
});
|
||||
|
||||
it('reports no prosthesis when neither a default nor an override was given', () => {
|
||||
expect(toVoiceIntent(wire()).prosthesis).toBeNull();
|
||||
it('yields an empty array, never null, when nothing was spoken about prosthesis', () => {
|
||||
expect(toVoiceIntent(wire()).prosthesis).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds a prosthesis intent from a default alone', () => {
|
||||
const result = toVoiceIntent(wire({ prosthesisDefaultType: 'pfm_crown' }));
|
||||
expect(result.prosthesis).toEqual({
|
||||
defaultType: 'pfm_crown',
|
||||
overrides: [],
|
||||
it('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,18 @@ export type WireDue = {
|
||||
d: number | null;
|
||||
};
|
||||
|
||||
export type WireProsthesisAssignment = {
|
||||
targets: WireToothIntent[];
|
||||
types: string[];
|
||||
spoken: string;
|
||||
};
|
||||
|
||||
export type WireVoiceIntent = {
|
||||
treatmentType: string | null;
|
||||
teeth: WireToothIntent[];
|
||||
connectedSpans: { from: WireToothIntent; to: WireToothIntent }[];
|
||||
comment: string | null;
|
||||
prosthesisDefaultType: string | null;
|
||||
prosthesisOverrides: { tooth: WireToothIntent; type: string }[];
|
||||
prosthesis: WireProsthesisAssignment[];
|
||||
labId: string | null;
|
||||
labMatchExact: boolean;
|
||||
due: WireDue;
|
||||
@@ -58,15 +63,21 @@ const TOOTH_SCHEMA = {
|
||||
properties: {
|
||||
spoken: {
|
||||
type: 'string',
|
||||
description: 'The exact transcript words for this tooth.',
|
||||
description: 'The exact transcript words for this tooth (or jaw).',
|
||||
},
|
||||
fdi: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
|
||||
'when the tooth was described in words instead of numbered.',
|
||||
'when the tooth was described in words instead of numbered, or the target is a jaw.',
|
||||
},
|
||||
arch: {
|
||||
type: ['string', 'null'],
|
||||
enum: ['upper', 'lower', 'both', null],
|
||||
description:
|
||||
'"both" is only ever used for a jaw-level prosthesis target (e.g. an appliance for ' +
|
||||
'both jaws), never for a single tooth.',
|
||||
},
|
||||
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
|
||||
side: {
|
||||
type: ['string', 'null'],
|
||||
enum: ['patient_right', 'patient_left', null],
|
||||
@@ -75,7 +86,33 @@ const TOOTH_SCHEMA = {
|
||||
position: {
|
||||
type: ['integer', 'null'],
|
||||
description:
|
||||
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
|
||||
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI ' +
|
||||
'code. Null when this target is a jaw rather than a tooth.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROSTHESIS_ASSIGNMENT_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['targets', 'types', 'spoken'],
|
||||
properties: {
|
||||
targets: {
|
||||
type: 'array',
|
||||
description: 'The teeth or jaws this instruction applies to.',
|
||||
items: TOOTH_SCHEMA,
|
||||
},
|
||||
types: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Prosthesis type codes to apply to every target above — a stack, e.g. an abutment ' +
|
||||
'plus a crown on the same tooth. A code from the CATEGORY or SUBCATEGORY lists is ' +
|
||||
'fine when only the general term was said.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
spoken: {
|
||||
type: 'string',
|
||||
description: 'The exact transcript words for this instruction.',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -88,8 +125,7 @@ export const VOICE_INTENT_JSON_SCHEMA = {
|
||||
'teeth',
|
||||
'connectedSpans',
|
||||
'comment',
|
||||
'prosthesisDefaultType',
|
||||
'prosthesisOverrides',
|
||||
'prosthesis',
|
||||
'labId',
|
||||
'labMatchExact',
|
||||
'due',
|
||||
@@ -114,19 +150,12 @@ export const VOICE_INTENT_JSON_SCHEMA = {
|
||||
type: ['string', 'null'],
|
||||
description: 'Clinical notes, in the spoken language.',
|
||||
},
|
||||
prosthesisDefaultType: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
'A prosthesis type CODE applied to every tooth unless overridden.',
|
||||
},
|
||||
prosthesisOverrides: {
|
||||
prosthesis: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['tooth', 'type'],
|
||||
properties: { tooth: TOOTH_SCHEMA, type: { type: 'string' } },
|
||||
},
|
||||
description:
|
||||
'One entry per spoken instruction: these targets get these jobs. No default and no ' +
|
||||
'overrides — every entry names its own targets.',
|
||||
items: PROSTHESIS_ASSIGNMENT_SCHEMA,
|
||||
},
|
||||
labId: {
|
||||
type: ['string', 'null'],
|
||||
@@ -190,7 +219,7 @@ function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
|
||||
}
|
||||
return {
|
||||
kind: 'positional',
|
||||
arch: wire?.arch as 'upper' | 'lower',
|
||||
arch: wire?.arch as 'upper' | 'lower' | 'both',
|
||||
side: wire?.side as 'patient_right' | 'patient_left',
|
||||
position: typeof wire?.position === 'number' ? wire.position : Number.NaN,
|
||||
spoken,
|
||||
@@ -236,36 +265,34 @@ function toDueIntent(wire: WireDue | undefined | null): DueIntent | null {
|
||||
}
|
||||
}
|
||||
|
||||
function toProsthesisAssignment(
|
||||
wire: WireProsthesisAssignment | undefined | null,
|
||||
): ProsthesisAssignment {
|
||||
const targets = Array.isArray(wire?.targets) ? wire.targets : [];
|
||||
const types = Array.isArray(wire?.types) ? wire.types : [];
|
||||
return {
|
||||
targets: targets.map(toToothIntent),
|
||||
types: types.filter((code): code is string => typeof code === 'string'),
|
||||
spoken: typeof wire?.spoken === 'string' ? wire.spoken : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function toVoiceIntent(wire: WireVoiceIntent): VoiceIntent {
|
||||
const teeth = Array.isArray(wire?.teeth) ? wire.teeth : [];
|
||||
const spans = Array.isArray(wire?.connectedSpans) ? wire.connectedSpans : [];
|
||||
const overrides = Array.isArray(wire?.prosthesisOverrides)
|
||||
? wire.prosthesisOverrides
|
||||
: [];
|
||||
const assignments = Array.isArray(wire?.prosthesis) ? wire.prosthesis : [];
|
||||
|
||||
const connectedSpans: ConnectedSpanIntent[] = spans.map((span) => ({
|
||||
from: toToothIntent(span?.from),
|
||||
to: toToothIntent(span?.to),
|
||||
}));
|
||||
|
||||
const hasProsthesis =
|
||||
wire?.prosthesisDefaultType != null || overrides.length > 0;
|
||||
const prosthesis: ProsthesisIntent | null = hasProsthesis
|
||||
? {
|
||||
defaultType: wire?.prosthesisDefaultType ?? null,
|
||||
overrides: overrides.map((o) => ({
|
||||
tooth: toToothIntent(o?.tooth),
|
||||
type: o?.type,
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
treatmentType: wire?.treatmentType ?? null,
|
||||
teeth: teeth.map(toToothIntent),
|
||||
connectedSpans,
|
||||
comment: wire?.comment ?? null,
|
||||
prosthesis,
|
||||
prosthesis: assignments.map(toProsthesisAssignment),
|
||||
labId: wire?.labId ?? null,
|
||||
labMatchExact: wire?.labMatchExact === true,
|
||||
due: toDueIntent(wire?.due),
|
||||
|
||||
@@ -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 }[];
|
||||
};
|
||||
|
||||
@@ -138,8 +138,12 @@ export class VoiceService {
|
||||
todayIso,
|
||||
weekStartJs: weekStartForLocale(catalogLocale),
|
||||
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
|
||||
prosthesisTypeCodes: new Set(
|
||||
catalog.prosthesisTypes.map((t) => t.code),
|
||||
prosthesisLeaves: catalog.prosthesisTypes,
|
||||
prosthesisCategoryCodes: new Set(
|
||||
catalog.prosthesisCategories.map((c) => c.code),
|
||||
),
|
||||
prosthesisSubcategoryCodes: new Set(
|
||||
catalog.prosthesisSubcategories.map((c) => c.code),
|
||||
),
|
||||
linkedLabIds: new Set(catalog.labs.map((l) => l.id)),
|
||||
});
|
||||
@@ -244,9 +248,17 @@ export class VoiceService {
|
||||
organizationId: string,
|
||||
locale: string,
|
||||
): Promise<ExtractionCatalog> {
|
||||
const [treatmentTypes, prosthesisTypes, labs] = await Promise.all([
|
||||
const [
|
||||
treatmentTypes,
|
||||
prosthesisTypes,
|
||||
prosthesisCategories,
|
||||
prosthesisSubcategories,
|
||||
labs,
|
||||
] = await Promise.all([
|
||||
this.treatmentCatalog.list(locale, null),
|
||||
this.prosthesisCatalog.list(locale),
|
||||
this.prosthesisCatalog.listCategories(locale),
|
||||
this.prosthesisCatalog.listSubcategories(locale),
|
||||
this.listLinkedLabs(organizationId),
|
||||
]);
|
||||
|
||||
@@ -254,10 +266,11 @@ export class VoiceService {
|
||||
treatmentTypes: treatmentTypes
|
||||
.filter((entry) => entry.availableInTreatment)
|
||||
.map((entry) => ({ code: entry.code, label: entry.label })),
|
||||
prosthesisTypes: prosthesisTypes.map((entry) => ({
|
||||
code: entry.code,
|
||||
label: entry.label,
|
||||
})),
|
||||
// `buildCatalog` used to throw away category/subcategory/chartRegion/stackGroup here —
|
||||
// the prompt now presents the catalog as the tree it is (§5).
|
||||
prosthesisTypes,
|
||||
prosthesisCategories,
|
||||
prosthesisSubcategories,
|
||||
labs,
|
||||
};
|
||||
}
|
||||
@@ -333,7 +346,7 @@ export class VoiceService {
|
||||
treatmentType: resolved.treatmentType != null,
|
||||
teeth: resolved.teeth.length,
|
||||
comment: resolved.comment != null,
|
||||
prosthesisComplete: resolved.prosthesis?.complete ?? null,
|
||||
prosthesisAssignments: resolved.prosthesisAssignments.length,
|
||||
lab: resolved.labId != null,
|
||||
dueDate: resolved.dueDate != null,
|
||||
},
|
||||
|
||||
@@ -8,14 +8,19 @@ import type { Arch, PatientSide } from '../../common/fdi';
|
||||
* mappings — quadrant mirroring and Jalali conversion — are testable rather than hopeful.
|
||||
*/
|
||||
|
||||
/** A single spoken tooth reference. `spoken` is the transcript span, echoed back to the user. */
|
||||
/**
|
||||
* A single spoken tooth reference, OR — inside a prosthesis assignment's `targets` — a jaw.
|
||||
* Nothing on the wire declares "this is a jaw": a positional reference with no `position` at
|
||||
* all names an arch instead of a tooth, and the resolver tells the two apart (§5, §6).
|
||||
* `arch: 'both'` only ever appears here, never on a resolved single tooth.
|
||||
*/
|
||||
export type ToothIntent =
|
||||
| { kind: 'explicit'; fdi: string; spoken: string }
|
||||
| {
|
||||
kind: 'positional';
|
||||
arch: Arch;
|
||||
arch: Arch | 'both';
|
||||
side: PatientSide;
|
||||
/** 1 = central incisor … 8 = third molar. */
|
||||
/** 1 = central incisor … 8 = third molar. Absent (NaN) when the target is a jaw. */
|
||||
position: number;
|
||||
spoken: string;
|
||||
};
|
||||
@@ -42,10 +47,18 @@ export type Weekday = (typeof WEEKDAYS)[number];
|
||||
/** Two teeth defining an inclusive connected (bridge) span. */
|
||||
export type ConnectedSpanIntent = { from: ToothIntent; to: ToothIntent };
|
||||
|
||||
export type ProsthesisIntent = {
|
||||
/** Catalog code applied to every tooth unless overridden. */
|
||||
defaultType: string | null;
|
||||
overrides: { tooth: ToothIntent; type: string }[];
|
||||
/**
|
||||
* One spoken instruction: these targets get these jobs. Replaces the old
|
||||
* `prosthesisDefaultType` + `prosthesisOverrides` pair — a default with per-tooth overrides
|
||||
* has a precedence rule, and a precedence rule has a wrong side (decision 35).
|
||||
*/
|
||||
export type ProsthesisAssignment = {
|
||||
/** Each one a tooth or a jaw — see `ToothIntent`. */
|
||||
targets: ToothIntent[];
|
||||
/** Leaf codes, or one category / subcategory code the clinician named generically. */
|
||||
types: string[];
|
||||
/** The transcript span, echoed back to the clinician. */
|
||||
spoken: string;
|
||||
};
|
||||
|
||||
export type VoiceIntent = {
|
||||
@@ -53,7 +66,8 @@ export type VoiceIntent = {
|
||||
teeth: ToothIntent[];
|
||||
connectedSpans: ConnectedSpanIntent[];
|
||||
comment: string | null;
|
||||
prosthesis: ProsthesisIntent | null;
|
||||
/** Empty array, never null. */
|
||||
prosthesis: ProsthesisAssignment[];
|
||||
/** Must be one of the linked-lab ids supplied in the prompt, or null. */
|
||||
labId: string | null;
|
||||
/** False when the spoken name only approximately matched — the UI then requires an explicit tick. */
|
||||
@@ -67,10 +81,15 @@ export type UnresolvedReason =
|
||||
| 'position_out_of_range'
|
||||
/** A position was understood but no quadrant was spoken — four teeth match. */
|
||||
| 'tooth_missing_quadrant'
|
||||
/** A category or subcategory was heard, not a material. */
|
||||
| 'prosthesis_type_ambiguous'
|
||||
/** A jaw-level appliance with no jaw spoken. */
|
||||
| 'arch_not_spoken'
|
||||
/** An arch code aimed at a tooth, or a tooth code aimed at a jaw. */
|
||||
| 'code_not_valid_for_target'
|
||||
| 'malformed'
|
||||
| 'span_not_same_arch'
|
||||
| 'unknown_catalog_code'
|
||||
| 'tooth_not_selected'
|
||||
| 'invalid_date';
|
||||
|
||||
export type UnresolvedItem = {
|
||||
@@ -78,8 +97,15 @@ export type UnresolvedItem = {
|
||||
spoken: string;
|
||||
reason: UnresolvedReason;
|
||||
/**
|
||||
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
|
||||
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
|
||||
* Values still consistent with what was heard: FDI codes for `tooth_missing_quadrant`,
|
||||
* leaf codes for `prosthesis_type_ambiguous`, `'upper'`/`'lower'` for `arch_not_spoken`. The
|
||||
* sheet offers these as chips.
|
||||
*/
|
||||
candidates?: string[];
|
||||
/**
|
||||
* Set only when this item was raised while resolving a `prosthesis` assignment's targets or
|
||||
* types. A picked chip then inherits that assignment's `types[]` (or supplies the missing
|
||||
* leaf to it) instead of resolving to a jobless tooth (decision 50).
|
||||
*/
|
||||
assignmentIndex?: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user