diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index c2e9e2e4..b5942d3a 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -156,6 +156,10 @@ const DIRECT_CODE_MAP: Record = { "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 }; diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index 911e5a40..7c00b7bd 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -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