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:
2026-06-22 12:15:22 -04:00
parent 91adac89e5
commit a2621eba6c
2 changed files with 46 additions and 6 deletions

View File

@@ -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 };

View File

@@ -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