From 011869f641c43d1e4a14402c4d3c3474a457e13f Mon Sep 17 00:00:00 2001 From: Gitead Date: Sat, 24 Oct 2026 12:38:29 -0400 Subject: [PATCH] fix: baby tooth (A-T) composites and extractions get correct CDT codes Composite parsing accepts primary tooth letters: A, B, I, J, K, L, S, T are back teeth (D2391-D2394), C-H and M-R front (D2330-D2335). Simple extraction on a primary tooth maps to D7111 unless D7140 is typed. Co-Authored-By: Claude Opus 5.5 --- apps/Backend/src/ai/cdt-lookup.ts | 62 ++++++++++++++++------ apps/Backend/src/ai/internal-chat-graph.ts | 2 + 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index 308b873a..0fa514c6 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -281,15 +281,23 @@ function extractToothSurface(input: string): { toothNumber?: string; toothSurfac }; } } - // Primary/baby tooth letter A-T (e.g. "# H", "#H") - const pm = input.match(/#\s*([A-Ta-t])\b/); - if (pm) return { toothNumber: pm[1]!.toUpperCase() }; + // Primary/baby tooth letter A-T (e.g. "# H", "#H", "#K O") + const pm = input.match(/#\s*([A-Ta-t])\b(?:\s+([A-Za-z]{1,5})\b)?/); + if (pm) { + const surfaces = (pm[2] ?? "").toUpperCase(); + return { + toothNumber: pm[1]!.toUpperCase(), + ...(surfaces && /^[OMDBLFIV]+$/.test(surfaces) ? { toothSurface: surfaces } : {}), + }; + } return {}; } // Composite filling tooth classification const FRONT_TEETH = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]); const BACK_TEETH = new Set([1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 28, 29, 30, 31, 32]); +// Primary (baby) teeth A-T: molars are back teeth, canines/incisors (C-H, M-R) are front +const PRIMARY_BACK_TEETH = new Set(["A", "B", "I", "J", "K", "L", "S", "T"]); // RCT tooth classification by tooth type const RCT_ANTERIOR = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]); // D3310 @@ -297,26 +305,36 @@ const RCT_PREMOLAR = new Set([4, 5, 12, 13, 20, 21, 28, 29]); const RCT_MOLAR = new Set([1, 2, 3, 14, 15, 16, 17, 18, 19, 30, 31, 32]); // D3330 /** - * Parse composite filling notation like "#29 OB", "composite #8 MO", "tooth 11 MOD". + * Parse composite filling notation like "#29 OB", "composite #8 MO", "tooth 11 MOD", "#K O". * Returns the correct D233x/D239x code based on tooth location and surface count. */ function parseCompositeCode(input: string): CdtMatch | null { const s = input.trim(); - // Match #NN SURFACES or tooth NN SURFACES (surfaces = 1-5 letters, valid dental surface chars) - const m = s.match(/#\s*(\d{1,2})\s+([A-Za-z]{1,5})\b/) ?? s.match(/\btooth\s+(\d{1,2})\s+([A-Za-z]{1,5})\b/i); + // Match #NN SURFACES, tooth NN SURFACES, or primary #X SURFACES (surfaces = 1-5 letters, valid dental surface chars) + const m = + s.match(/#\s*(\d{1,2}|[A-Ta-t])\s+([A-Za-z]{1,5})\b/) ?? + s.match(/\btooth\s+(\d{1,2}|[A-Ta-t])\s+([A-Za-z]{1,5})\b/i); if (!m) return null; - const toothNum = parseInt(m[1]!, 10); + const tooth = m[1]!.toUpperCase(); const surfaces = m[2]!; - if (toothNum < 1 || toothNum > 32) return null; // Surfaces must only be dental surface letters (O M D B L F I V) if (!/^[OMDBLFIV]+$/i.test(surfaces)) return null; const surfaceCount = surfaces.length; - const isFront = FRONT_TEETH.has(toothNum); - - if (!isFront && !BACK_TEETH.has(toothNum)) return null; + let isFront: boolean; + let toothNumber: string; + if (/^[A-T]$/.test(tooth)) { + isFront = !PRIMARY_BACK_TEETH.has(tooth); + toothNumber = tooth; + } else { + const toothNum = parseInt(tooth, 10); + if (toothNum < 1 || toothNum > 32) return null; + isFront = FRONT_TEETH.has(toothNum); + if (!isFront && !BACK_TEETH.has(toothNum)) return null; + toothNumber = String(toothNum); + } let code: string; if (isFront) { @@ -326,7 +344,7 @@ function parseCompositeCode(input: string): CdtMatch | null { } const row = ALL_CODES.find((r) => r["Procedure Code"] === code); - return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum), toothSurface: surfaces.toUpperCase() }; + return { code, description: row?.Description ?? code, input, toothNumber, toothSurface: surfaces.toUpperCase() }; } /** @@ -608,8 +626,9 @@ function matchOne(input: string): CdtMatch | null { return { code: bestEntry.code, description: bestEntry.description, input }; } -const TOOTH_SURFACE_RE = /#?\s*(\d{1,2})\s+([OMDBLFIV]{1,5})\b/gi; -const BARE_TOOTH_SURFACE_RE = /^#?\s*(\d{1,2})\s+([OMDBLFIV]{1,5})$/i; +// Primary letters (A-T) need the "#" so ordinary words aren't read as teeth +const TOOTH_SURFACE_RE = /(?:#\s*([A-T])|#?\s*(\d{1,2}))\s+([OMDBLFIV]{1,5})\b/gi; +const BARE_TOOTH_SURFACE_RE = /^#?\s*(\d{1,2}|[A-T])\s+([OMDBLFIV]{1,5})$/i; /** * Composite lists often give the "#" only on the first tooth: @@ -624,13 +643,13 @@ function expandCompositeLists(procedureNames: string[]): string[] { const s = name.trim(); if (prevComposite && BARE_TOOTH_SURFACE_RE.test(s)) { const m = s.match(BARE_TOOTH_SURFACE_RE)!; - out.push(`comp #${m[1]} ${m[2]}`); + out.push(`comp #${m[1]!.toUpperCase()} ${m[2]}`); continue; } - const isComposite = /^(comp|composite|resin|filling)\b/i.test(s) || /^#\s*\d{1,2}\s+[OMDBLFIV]{1,5}\b/i.test(s); + const isComposite = /^(comp|composite|resin|filling)\b/i.test(s) || /^#\s*(\d{1,2}|[A-T])\s+[OMDBLFIV]{1,5}\b/i.test(s); const teeth = isComposite ? [...s.matchAll(TOOTH_SURFACE_RE)] : []; if (teeth.length > 1) { - for (const m of teeth) out.push(`comp #${m[1]} ${m[2]}`); + for (const m of teeth) out.push(`comp #${(m[1] ?? m[2])!.toUpperCase()} ${m[3]}`); } else { out.push(name); } @@ -791,5 +810,14 @@ export function lookupCdtCodes( } } + // Simple extraction on a primary (baby) tooth A-T is D7111, unless D7140 was typed explicitly + const primaryExtRow = ALL_CODES.find((r) => r["Procedure Code"] === "D7111"); + for (const r of results) { + if (r.code === "D7140" && /^[A-T]$/.test(r.toothNumber ?? "") && !/\bD?7140\b/i.test(r.input)) { + r.code = "D7111"; + r.description = primaryExtRow?.Description ?? "D7111"; + } + } + return results; } diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index fcc0b6fe..3e834a3b 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -173,6 +173,8 @@ Rules: e.g. "composite #29 O", "#8 MO", "composite #11 MOD", "# 10 DL", "# 11 ML", "#8 ml", "comp #8 ml" — keep the #number and surface letters together as one entry A tooth list after one composite keeps the composite for every tooth: "comp on #18 O, 20 O, 21 O" → ["comp #18 O", "comp #20 O", "comp #21 O"] + Baby (primary) teeth use letters A-T instead of numbers; keep them as-is: "comp # 19 OB, # K O, # T O, #30 OB" → + ["comp #19 OB", "comp #K O", "comp #T O", "comp #30 OB"] Note: "comp" is short for "composite". "# 10 DL", "composite on # 10 DL", and "comp # 10 DL" are all the same — preserve the space-after-# as-is IMPORTANT: Any letter(s) immediately after a tooth number are DENTAL SURFACE abbreviations indicating a composite filling: M = Mesial, D = Distal, L = Lingual, F = Facial, B = Buccal, O = Occlusal, V = Vestibular, I = Incisal