fix: recognize NPA shorthand and ordinal PA names (3rd pa → D0230)
- Add parseMultiPaCode: "4PA #3 #14 #19 #30" expands to D0220 + 3× D0230 with tooth numbers - Add "3rd pa"–"6th pa" direct aliases → D0230 as fallback - Tighten LLM prompt to always use "2nd pa" for all additional PAs, never "3rd pa" etc. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -156,6 +156,10 @@ const DIRECT_CODE_MAP: Record<string, string> = {
|
||||
"1 pa": "D0220",
|
||||
"first pa": "D0220",
|
||||
"2nd pa": "D0230",
|
||||
"3rd pa": "D0230",
|
||||
"4th pa": "D0230",
|
||||
"5th pa": "D0230",
|
||||
"6th pa": "D0230",
|
||||
"additional pa": "D0230",
|
||||
"cbct": "D0367",
|
||||
"cone beam": "D0367",
|
||||
@@ -287,6 +291,37 @@ function parseCompositeCode(input: string): CdtMatch | null {
|
||||
return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum), toothSurface: surfaces.toUpperCase() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multi-PA shorthand: "4PA", "4 PA", "4PA #3 #14 #19 #30", etc.
|
||||
* NPA → 1× D0220 (first image) + (N-1)× D0230 (each additional).
|
||||
* Tooth numbers, if provided in order, are assigned to each code.
|
||||
*/
|
||||
function parseMultiPaCode(input: string): CdtMatch[] | null {
|
||||
const m = input.trim().match(/^(\d+)\s*pa\b/i);
|
||||
if (!m) return null;
|
||||
|
||||
const count = parseInt(m[1]!, 10);
|
||||
if (count < 1 || count > 20) return null;
|
||||
|
||||
// Extract all tooth numbers from the full input string
|
||||
const toothMatches = [...input.matchAll(/#\s*(\d{1,2})\b/g)];
|
||||
const teeth: string[] = toothMatches
|
||||
.map((tm) => tm[1]!)
|
||||
.filter((t) => { const n = parseInt(t, 10); return n >= 1 && n <= 32; });
|
||||
|
||||
const d0220 = ALL_CODES.find((r) => r["Procedure Code"] === "D0220");
|
||||
const d0230 = ALL_CODES.find((r) => r["Procedure Code"] === "D0230");
|
||||
|
||||
const results: CdtMatch[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const code = i === 0 ? "D0220" : "D0230";
|
||||
const desc = i === 0 ? (d0220?.Description ?? "D0220") : (d0230?.Description ?? "D0230");
|
||||
const tooth = teeth[i];
|
||||
results.push({ code, description: desc, input, ...(tooth ? { toothNumber: tooth } : {}) });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SRP code with quadrant: "D4341 UL", "4341 LR", "D4342 UR", etc.
|
||||
* UL/UR/LL/LR only appear with D4341 or D4342.
|
||||
@@ -443,19 +478,23 @@ export function lookupCdtCodes(
|
||||
return { code, description: row?.Description ?? code, input: name, ...toothInfo };
|
||||
}
|
||||
|
||||
// 2. Composite filling by tooth# and surfaces — also determines CDT code from tooth position
|
||||
// 2. Multi-PA shorthand: "4PA", "4PA #3 #14 #19 #30", etc.
|
||||
const multiPaMatches = parseMultiPaCode(name);
|
||||
if (multiPaMatches) return multiPaMatches;
|
||||
|
||||
// 3. 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
|
||||
|
||||
// 2b. RCT by tooth# — D3310/D3320/D3330 based on tooth position
|
||||
// 4. RCT by tooth# — D3310/D3320/D3330 based on tooth position
|
||||
const rctMatch = parseRctCode(name);
|
||||
if (rctMatch) return rctMatch;
|
||||
|
||||
// 2c. SRP + quadrant — D4341/D4342 with UL/UR/LL/LR
|
||||
// 5. SRP + quadrant — D4341/D4342 with UL/UR/LL/LR
|
||||
const srpMatch = parseSrpCode(name);
|
||||
if (srpMatch) return srpMatch;
|
||||
|
||||
// 3. Hardcoded direct code map (short abbreviations / codes not in MH schedule)
|
||||
// 6. 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]!;
|
||||
@@ -463,7 +502,7 @@ export function lookupCdtCodes(
|
||||
return { code, description: row?.Description ?? code, input: name, ...toothInfo };
|
||||
}
|
||||
|
||||
// 4. Hardcoded alias + keyword search (try stripped name so tooth# doesn't break matching)
|
||||
// 7. 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 };
|
||||
|
||||
|
||||
@@ -148,9 +148,10 @@ Rules:
|
||||
- For SRP with a quadrant abbreviation (UL, UR, LL, LR), keep the code and quadrant together as one entry:
|
||||
e.g. "D4341 UL", "4341 LR", "D4342 UR" — the quadrant always travels with the SRP code
|
||||
- For multiple PA X-rays with tooth numbers, expand each PA into its own entry:
|
||||
"1 pa, #T" for the first tooth, "2nd pa, #T" for each additional tooth
|
||||
ALWAYS use "1 pa, #T" for the first tooth and "2nd pa, #T" for EVERY additional tooth (never "3rd pa", "4th pa", etc.)
|
||||
e.g. "2 PA (#30, 15)" → ["1 pa, #30", "2nd pa, #15"]
|
||||
e.g. "3 PA (#3, 14, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #30"]
|
||||
e.g. "4 PA (#3, 14, 19, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #19", "2nd pa, #30"]
|
||||
e.g. "2 pa #3 #14" → ["1 pa, #3", "2nd pa, #14"]
|
||||
- insuranceHint is only set when the user explicitly names an insurance in the message
|
||||
- renderingProvider is only set when the user explicitly names a treating/rendering provider or doctor
|
||||
|
||||
Reference in New Issue
Block a user