diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index 426f6475..a50505b7 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -61,6 +61,12 @@ const ALIAS_MAP: Record = { "child prophy": "prophylaxis child", "pedo cleaning": "prophylaxis child", "kid prophy": "prophylaxis child", + // No adult/child qualifier given — default to adult here; the workflow layer + // (internal-chat-workflow.ts: correctProphyForAge) looks up the patient's actual + // age and swaps this to D1120 when they're under 14, so age always wins. + "prophy": "prophylaxis adult", + "prophylaxis": "prophylaxis adult", + "cleaning": "prophylaxis adult", // X-rays "full mouth xray": "intraoral complete series", "fmx": "intraoral complete series", diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index ca58b6f4..cc03c8bd 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -136,18 +136,35 @@ function calcAge(dob: string | null | undefined, on?: string | null): number | n } catch { return null; } } -function checkD1120Age( - matched: CdtResult[], +/** + * The patient's actual age is authoritative over whatever the user typed in notes + * ("adult prophy" / "child prophy") — D1110 is for 14+, D1120 is for under 14. + * Silently rewrites the matched code/description to match age and returns an + * informational note when a correction was made, so the user is told why what + * they typed differs from what's being claimed. + */ +function correctProphyForAge( + results: CdtResult[], patientName: string, dob: string | null | undefined, serviceDate?: string | null -): ChatResponse | null { - if (!matched.some((r) => r.code === "D1120")) return null; +): { results: CdtResult[]; note: string | null } { const age = calcAge(dob, serviceDate); - if (age === null || age < 14) return null; - return { - reply: `${patientName} is ${age} years old. D1120 (child prophy) is only for patients under 14 — I recommend using D1110 (adult prophy) instead. If you still need D1120, please claim manually for this patient.`, - }; + if (age === null) return { results, note: null }; + + let note: string | null = null; + const corrected = results.map((r) => { + if (r.code === "D1110" && age < 14) { + note = `Note: ${patientName} is ${age}, so I used D1120 (child prophy) instead of D1110 — age overrides what was written.`; + return { ...r, code: "D1120", description: "Prophylaxis – child, 0-13 yo" }; + } + if (r.code === "D1120" && age >= 14) { + note = `Note: ${patientName} is ${age}, so I used D1110 (adult prophy) instead of D1120 — age overrides what was written.`; + return { ...r, code: "D1110", description: "Prophylaxis – adult, 14 yo or older" }; + } + return r; + }); + return { results: corrected, note }; } function patientToResult(p: any): ResolvedPatient { @@ -703,7 +720,9 @@ async function handleBatchCheckAndClaim( siteKey: string; autoCheck: string; patient: ResolvedPatient | null; + matchedCodes: CdtResult[]; }[] = []; + const ageNotes: string[] = []; for (const { memberId, dob } of pairs) { const id = memberId?.trim(); @@ -720,12 +739,19 @@ async function handleBatchCheckAndClaim( c.insuranceHint ?? null ) ?? "MH"; + const label = patient + ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() + : `ID ${id}`; + const { results: patientMatched, note } = correctProphyForAge(matched, label, resolvedDob, c.appointmentDate); + if (note) ageNotes.push(note); + resolved.push({ memberId: id, dob: resolvedDob, siteKey, autoCheck: siteKeyToAutoCheck(siteKey, resolvedDob), patient, + matchedCodes: patientMatched, }); } @@ -741,11 +767,23 @@ async function handleBatchCheckAndClaim( }); const codeList = matched.map((r) => `${r.code} (${r.description})`).join(", "); + let reply = `Ready to check eligibility and claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`; + if (ageNotes.length > 0) { + reply += ` ${ageNotes.join(" ")}`; + } + return { - reply: `Ready to check eligibility and claim ${codeList} for ${resolved.length} patients: ${labels.join(", ")}.`, + reply, action: "batch_check_and_claim_ready", actionData: { - queue: resolved, + queue: resolved.map((r) => ({ + memberId: r.memberId, + dob: r.dob, + siteKey: r.siteKey, + autoCheck: r.autoCheck, + patient: r.patient, + matchedCodes: r.matchedCodes.map((m) => ({ code: m.code!, description: m.description, toothNumber: m.toothNumber, toothSurface: m.toothSurface, quad: m.quad })), + })), matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), renderingProvider: c.renderingProvider ?? null, }, @@ -794,8 +832,10 @@ async function handleBatchClaim( siteKey: string; serviceDate: string | null; appointmentId: number | null; + matchedCodes: CdtResult[]; }[] = []; const notFound: string[] = []; + const ageNotes: string[] = []; for (const name of names) { const trimmed = name.trim(); @@ -808,8 +848,8 @@ async function handleBatchClaim( const patient = patientToResult(raw); const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); - const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate); - if (d1120Warning) return d1120Warning; + const { results: patientMatched, note } = correctProphyForAge(matched, fullName, patient.dateOfBirth, c.appointmentDate); + if (note) ageNotes.push(note); const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH"; @@ -820,7 +860,7 @@ async function handleBatchClaim( serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; } - resolved.push({ patient, siteKey, serviceDate, appointmentId }); + resolved.push({ patient, siteKey, serviceDate, appointmentId, matchedCodes: patientMatched }); } if (notFound.length > 0 && resolved.length === 0) { @@ -835,6 +875,9 @@ async function handleBatchClaim( if (notFound.length > 0) { reply += ` Could not find: ${notFound.join(", ")}.`; } + if (ageNotes.length > 0) { + reply += ` ${ageNotes.join(" ")}`; + } return { reply, @@ -845,6 +888,7 @@ async function handleBatchClaim( siteKey: r.siteKey, serviceDate: r.serviceDate, appointmentId: r.appointmentId, + matchedCodes: r.matchedCodes.map((m) => ({ code: m.code!, description: m.description, toothNumber: m.toothNumber, toothSurface: m.toothSurface, quad: m.quad })), })), matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), renderingProvider: c.renderingProvider ?? null, @@ -879,6 +923,7 @@ async function handleMultiClaim( matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[]; }[] = []; const notFound: string[] = []; + const ageNotes: string[] = []; for (const group of groups) { const name = group.patientName?.trim(); @@ -908,8 +953,8 @@ async function handleMultiClaim( const patient = patientToResult(raw); const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); - const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate); - if (d1120Warning) return d1120Warning; + const { results: correctedMatched, note } = correctProphyForAge(matched, fullName, patient.dateOfBirth, c.appointmentDate); + if (note) ageNotes.push(note); const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH"; @@ -924,7 +969,7 @@ async function handleMultiClaim( siteKey, serviceDate, appointmentId: null, - matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), + matchedCodes: correctedMatched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), }); } @@ -941,6 +986,9 @@ async function handleMultiClaim( if (notFound.length > 0) { reply += ` Could not find: ${notFound.join(", ")}.`; } + if (ageNotes.length > 0) { + reply += ` ${ageNotes.join(" ")}`; + } return { reply, @@ -1049,13 +1097,13 @@ async function handleCheckAndClaim( ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : `Member ID ${memberId}`; - // D1120 age check — recommend D1110 for patients 14+ - const d1120Warning = checkD1120Age(matched, label, patient?.dateOfBirth ?? resolvedDob, c.appointmentDate); - if (d1120Warning) return d1120Warning; + // Patient's age is authoritative over notes text for prophy code (D1110 vs D1120) + const { results: correctedMatched, note: ageNote } = correctProphyForAge(matched, label, patient?.dateOfBirth ?? resolvedDob, c.appointmentDate); - const reply = `Ready to check eligibility for ${label} and claim: ${ - matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped" + let reply = `Ready to check eligibility for ${label} and claim: ${ + correctedMatched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures mapped" }.`; + if (ageNote) reply += ` ${ageNote}`; return { reply, @@ -1067,7 +1115,7 @@ async function handleCheckAndClaim( siteKey, autoCheck: siteKeyToAutoCheck(siteKey), cdtResults, - matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), + matchedCodes: correctedMatched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), serviceDate: c.appointmentDate ?? null, renderingProvider: c.renderingProvider ?? null, }, @@ -1107,7 +1155,7 @@ async function handleClaimOnly( } const cdtResults = lookupCdtCodes(procedureNames, customAliases); - const matched = cdtResults.filter((r) => r.code !== null); + const rawMatched = cdtResults.filter((r) => r.code !== null); const unmatched = cdtResults.filter((r) => r.code === null); // Block if any term couldn't be mapped — ask instead of proceeding @@ -1116,13 +1164,12 @@ async function handleClaimOnly( return { reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D0272)`, action: "need_cdt_clarification", - actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) }, + actionData: { unknownPhrases: phrases, matchedSoFar: rawMatched.map((r) => ({ code: r.code!, description: r.description })) }, }; } - // D1120 age check — recommend D1110 for patients 14+ - const d1120Warning = checkD1120Age(matched, fullName, patient.dateOfBirth, c.appointmentDate); - if (d1120Warning) return d1120Warning; + // Patient's age is authoritative over notes text for prophy code (D1110 vs D1120) + const { results: matched, note: ageNote } = correctProphyForAge(rawMatched, fullName, patient.dateOfBirth, c.appointmentDate); // Resolve service date: use explicit date from message, then latest appointment, then ask let serviceDate: string | null = c.appointmentDate ?? null; @@ -1180,6 +1227,7 @@ async function handleClaimOnly( if (unmatched.length > 0) { reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — verify manually.`; } + if (ageNote) reply += ` ${ageNote}`; return { reply, diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index d863d0e7..5bf98b7f 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -256,12 +256,12 @@ export function ChatbotButton() { renderingProvider: string | null; } | null>(null); const [batchCheckAndClaimData, setBatchCheckAndClaimData] = useState<{ - queue: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[]; + queue: { memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; matchedCodes?: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[] }[]; matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[]; renderingProvider: string | null; } | null>(null); const [batchClaimData, setBatchClaimData] = useState<{ - queue: { patient: PatientResult; siteKey: string; serviceDate: string | null; appointmentId: number | null }[]; + queue: { patient: PatientResult; siteKey: string; serviceDate: string | null; appointmentId: number | null; matchedCodes?: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[] }[]; matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[]; renderingProvider: string | null; } | null>(null); @@ -1461,7 +1461,7 @@ export function ChatbotButton() { })); } sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({ - codes: matchedCodes, + codes: first!.matchedCodes?.length ? first!.matchedCodes : matchedCodes, siteKey: first!.siteKey, patientId: first!.patient?.id ?? null, memberId: first!.memberId, @@ -1492,6 +1492,9 @@ export function ChatbotButton() { {batchClaimData.queue.map((item, i) => (

{i + 1}. {item.patient.firstName} {item.patient.lastName} + {item.matchedCodes?.some((c, idx) => c.code !== batchClaimData.matchedCodes[idx]?.code) && ( + — {item.matchedCodes.map((c) => c.code).join(", ")} (adjusted for age) + )}

))} {batchClaimData.matchedCodes.length > 0 && ( @@ -1520,9 +1523,10 @@ export function ChatbotButton() { renderingProvider, })); } - if (first!.patient.id && matchedCodes.length > 0) { + const firstCodes = first!.matchedCodes?.length ? first!.matchedCodes : matchedCodes; + if (first!.patient.id && firstCodes.length > 0) { sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ - codes: matchedCodes, + codes: firstCodes, siteKey: first!.siteKey, serviceDate: first!.serviceDate, autoSubmit: true, diff --git a/apps/Frontend/src/pages/claims-page.tsx b/apps/Frontend/src/pages/claims-page.tsx index 56b0233c..373af050 100755 --- a/apps/Frontend/src/pages/claims-page.tsx +++ b/apps/Frontend/src/pages/claims-page.tsx @@ -381,8 +381,9 @@ export default function ClaimsPage() { } else { sessionStorage.removeItem("chatbot_check_claim_queue"); } + const codes = next.matchedCodes?.length > 0 ? next.matchedCodes : matchedCodes; sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({ - codes: matchedCodes, + codes, siteKey: next.siteKey, patientId: next.patient?.id ?? null, memberId: next.memberId,