From 65f14b5e61a57a8591bb225bfed5b61c5d37b53f Mon Sep 17 00:00:00 2001 From: Gitead Date: Fri, 9 Oct 2026 23:18:29 -0400 Subject: [PATCH] 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 --- apps/Backend/src/ai/cdt-lookup.ts | 98 +++++++++++++++++-- apps/Backend/src/data/procedureCodes.json | 2 +- .../src/assets/data/procedureCodesMH.json | 2 +- 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index a50505b7..79f84ff4 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -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 = { + 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 = { + 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; } diff --git a/apps/Backend/src/data/procedureCodes.json b/apps/Backend/src/data/procedureCodes.json index ec281470..fd3142ff 100755 --- a/apps/Backend/src/data/procedureCodes.json +++ b/apps/Backend/src/data/procedureCodes.json @@ -341,7 +341,7 @@ }, { "Procedure Code": "D6245", - "Description": "porcelain, not for united", + "Description": "porcelain", "Price": "1400" }, { diff --git a/apps/Frontend/src/assets/data/procedureCodesMH.json b/apps/Frontend/src/assets/data/procedureCodesMH.json index ec281470..fd3142ff 100755 --- a/apps/Frontend/src/assets/data/procedureCodesMH.json +++ b/apps/Frontend/src/assets/data/procedureCodesMH.json @@ -341,7 +341,7 @@ }, { "Procedure Code": "D6245", - "Description": "porcelain, not for united", + "Description": "porcelain", "Price": "1400" }, {