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 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:38:29 -04:00
co-authored by Claude Opus 5.5
parent d600d94497
commit 011869f641
2 changed files with 47 additions and 17 deletions
+45 -17
View File
@@ -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;
}
@@ -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