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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user