fix: pontic code matches crown material (D2740 crowns -> D6245 pontic)

Pontic previously fell through to keyword search and always hit D6241.
Now an explicit material on the pontic wins; otherwise it follows the
crowns in the same request, defaulting to D6245. Also drop 'not for
united' from the D6245 description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 23:18:29 -04:00
co-authored by Claude Opus 5.5
parent 1abc2c705d
commit 65f14b5e61
3 changed files with 90 additions and 12 deletions
+88 -10
View File
@@ -395,6 +395,60 @@ function parseRecementCode(input: string): CdtMatch | null {
return { code: "D2920", description: row?.Description ?? "D2920", input, ...(toothNumber ? { toothNumber } : {}) };
}
// Bridge pontic codes, keyed by the crown/retainer code of the same material.
// A pontic is made to match its abutment crowns, so "crown #11, 13, pontic #12"
// with D2740 crowns must give a D6245 (porcelain/ceramic) pontic.
const PONTIC_FOR_CROWN: Record<string, string> = {
D2740: "D6245", D6740: "D6245", // porcelain/ceramic
D2750: "D6240", D6750: "D6240", // porcelain fused to high noble
D2751: "D6241", D6751: "D6241", // porcelain fused to base metal
D2752: "D6242", D6752: "D6242", // porcelain fused to noble
D2790: "D6210", D6790: "D6210", // full cast high noble
D2791: "D6211", D6791: "D6211", // full cast base metal
D2792: "D6212", D6792: "D6212", // full cast noble
};
const DEFAULT_PONTIC = "D6245";
// Pontic codes mostly aren't in the MH fee schedule JSON — fallback descriptions
const PONTIC_DESCRIPTIONS: Record<string, string> = {
D6210: "Pontic - cast high noble metal",
D6211: "Pontic - cast predominantly base metal",
D6212: "Pontic - cast noble metal",
D6240: "Pontic - porcelain fused to high noble metal",
D6241: "Pontic - porcelain fused to predominantly base metal",
D6242: "Pontic - porcelain fused to noble metal",
D6245: "Pontic - porcelain/ceramic",
};
/**
* Parse pontic wording: "pontic #12", "pfm pontic #12", "zirconia pontic".
* Returns the code plus whether the material was stated explicitly; when it
* wasn't, lookupCdtCodes later matches it to the crowns in the same request.
*/
function parsePonticCode(input: string): { match: CdtMatch; explicit: boolean } | null {
const lower = input.toLowerCase();
if (!/\bpontics?\b/.test(lower)) return null;
let code: string | null = null;
if (/\b(porcelain|ceramic|zirconia|zirc|emax|e-max)\b/.test(lower) && !/\b(pfm|fused|metal)\b/.test(lower)) code = "D6245";
else if (/\bbase\s*metal\b/.test(lower)) code = "D6241";
else if (/\bhigh\s*noble\b/.test(lower) || /\b(pfm|pfc|fused)\b/.test(lower)) code = "D6240";
else if (/\bnoble\b/.test(lower)) code = "D6242";
const explicit = code !== null;
const finalCode = code ?? DEFAULT_PONTIC;
const row = ALL_CODES.find((r) => r["Procedure Code"] === finalCode);
return {
match: {
code: finalCode,
description: row?.Description ?? PONTIC_DESCRIPTIONS[finalCode] ?? finalCode,
input,
...extractToothSurface(input),
},
explicit,
};
}
/**
* Parse RCT/root-canal notation like "rct #29", "#14 rct", "root canal #6".
* Returns D3310 (anterior), D3320 (premolar), or D3330 (molar) based on tooth number.
@@ -560,7 +614,10 @@ export function lookupCdtCodes(
if (phrase && cdtCode) customMap[phrase.toLowerCase().trim()] = cdtCode.toUpperCase().trim();
}
return procedureNames.flatMap((name) => {
// Pontics with no stated material — re-coded below to match the crowns
const inferredPontics: CdtMatch[] = [];
const results = procedureNames.flatMap((name): (CdtMatch | { code: null; description: string; input: string })[] => {
const cleaned = name.trim().toLowerCase();
// If the LLM emitted a bare tooth-number token like "# 32" or "#32" with no procedure,
@@ -583,13 +640,20 @@ export function lookupCdtCodes(
if (customKey) {
const code = customMap[customKey]!;
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
return { code, description: row?.Description ?? code, input: name, ...toothInfo };
return [{ code, description: row?.Description ?? code, input: name, ...toothInfo }];
}
// 2. Re-cement wording — outranks "crown"/etc. elsewhere in the same entry,
// regardless of word order or tooth-number format (e.g. "#7 crown recement").
const recementMatch = parseRecementCode(name);
if (recementMatch) return recementMatch;
if (recementMatch) return [recementMatch];
// 2b. Bridge pontic — material from the wording, else from the crowns (below)
const pontic = parsePonticCode(name);
if (pontic) {
if (!pontic.explicit) inferredPontics.push(pontic.match);
return [pontic.match];
}
// 3. Multi-PA shorthand: "4PA", "4PA #3 #14 #19 #30", etc.
const multiPaMatches = parseMultiPaCode(name);
@@ -597,32 +661,46 @@ export function lookupCdtCodes(
// 4. Composite filling by tooth# and surfaces — also determines CDT code from tooth position
const compositeMatch = parseCompositeCode(name);
if (compositeMatch) return compositeMatch; // already carries toothNumber/toothSurface
if (compositeMatch) return [compositeMatch]; // already carries toothNumber/toothSurface
// 5. RCT by tooth# — D3310/D3320/D3330 based on tooth position
const rctMatch = parseRctCode(name);
if (rctMatch) return rctMatch;
if (rctMatch) return [rctMatch];
// 6. SRP + quadrant — D4341/D4342 with UL/UR/LL/LR
const srpMatch = parseSrpCode(name);
if (srpMatch) return srpMatch;
if (srpMatch) return [srpMatch];
// 7. Hardcoded direct code map (short abbreviations / codes not in MH schedule)
const directKey = DIRECT_CODE_MAP[cleaned] ? cleaned : DIRECT_CODE_MAP[strippedCleaned] ? strippedCleaned : null;
if (directKey) {
const code = DIRECT_CODE_MAP[directKey]!;
const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code);
return { code, description: row?.Description ?? code, input: name, ...toothInfo };
return [{ code, description: row?.Description ?? code, input: name, ...toothInfo }];
}
// 8. Hardcoded alias + keyword search (try stripped name so tooth# doesn't break matching)
const match = matchOne(strippedCleaned !== cleaned ? strippedCleaned : name);
if (match) return { ...match, input: name, ...toothInfo };
if (match) return [{ ...match, input: name, ...toothInfo }];
return {
return [{
code: null,
description: `No CDT code found for "${name}"`,
input: name,
};
}];
});
// A pontic matches its abutment crowns' material: use the first crown/retainer
// in the same request that has a pontic equivalent.
if (inferredPontics.length > 0) {
const crown = results.find((r) => r.code && PONTIC_FOR_CROWN[r.code]);
const ponticCode = crown?.code ? PONTIC_FOR_CROWN[crown.code]! : DEFAULT_PONTIC;
const row = ALL_CODES.find((r) => r["Procedure Code"] === ponticCode);
for (const p of inferredPontics) {
p.code = ponticCode;
p.description = row?.Description ?? PONTIC_DESCRIPTIONS[ponticCode] ?? ponticCode;
}
}
return results;
}
+1 -1
View File
@@ -341,7 +341,7 @@
},
{
"Procedure Code": "D6245",
"Description": "porcelain, not for united",
"Description": "porcelain",
"Price": "1400"
},
{
@@ -341,7 +341,7 @@
},
{
"Procedure Code": "D6245",
"Description": "porcelain, not for united",
"Description": "porcelain",
"Price": "1400"
},
{