From 967a53fc6c599ab05991b3af0ae291afc2a0b5f2 Mon Sep 17 00:00:00 2001 From: ff Date: Sun, 7 Jun 2026 23:47:48 -0400 Subject: [PATCH] feat: AI chat file uploads, RCT/PA tooth mapping, check+claim flow, service date column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chatbot: add paperclip button to attach X-ray/PDF files to claim submissions; files flow through chatbotFileStore into claim-form uploadedFiles for Selenium - CDT lookup: auto-select D3310/D3320/D3330 by tooth number for RCT; strip #NN from procedure names before alias lookup so "1 pa, #3" → D0220 tooth 3; add "1 pa" alias for D0220; expand multi-PA notation via AI prompt rule - AI classifier: add navigate_eligibility intent ("check mh" → /insurance-status); fix duplicate intent entry; add RCT and multi-PA prompt rules - Check+claim flow: pass serviceDate through check_and_claim_ready actionData; tryClaimFromChatbot skips PDF preview and navigates straight to claims with memberId fallback lookup; wired into all provider onPdfReady callbacks - Claims table: add Service Date column between Patient Name and Submission Date Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/ai/cdt-lookup.ts | 66 ++++++++++-- apps/Backend/src/ai/internal-chat-graph.ts | 12 ++- apps/Backend/src/ai/internal-chat-workflow.ts | 10 ++ .../src/components/claims/claim-form.tsx | 8 ++ .../components/claims/claims-recent-table.tsx | 6 ++ .../src/components/layout/chatbot.tsx | 68 +++++++++++- apps/Frontend/src/lib/chatbotFileStore.ts | 12 +++ .../src/pages/insurance-status-page.tsx | 102 ++++++++++++------ 8 files changed, 243 insertions(+), 41 deletions(-) create mode 100644 apps/Frontend/src/lib/chatbotFileStore.ts diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index 095442e3..e30f269a 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -152,6 +152,7 @@ const DIRECT_CODE_MAP: Record = { "4bw": "D0274", "4 bw": "D0274", "pa": "D0220", + "1 pa": "D0220", "first pa": "D0220", "2nd pa": "D0230", "additional pa": "D0230", @@ -238,6 +239,11 @@ function extractToothSurface(input: string): { toothNumber?: string; toothSurfac const FRONT_TEETH = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]); const BACK_TEETH = new Set([1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 28, 29, 30, 31, 32]); +// RCT tooth classification by tooth type +const RCT_ANTERIOR = new Set([6, 7, 8, 9, 10, 11, 22, 23, 24, 25, 26, 27]); // D3310 +const RCT_PREMOLAR = new Set([4, 5, 12, 13, 20, 21, 28, 29]); // D3320 +const RCT_MOLAR = new Set([1, 2, 3, 14, 15, 16, 17, 18, 19, 30, 31, 32]); // D3330 + /** * Parse composite filling notation like "#29 OB", "composite #8 MO", "tooth 11 MOD". * Returns the correct D233x/D239x code based on tooth location and surface count. @@ -271,6 +277,43 @@ function parseCompositeCode(input: string): CdtMatch | null { return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum), toothSurface: surfaces.toUpperCase() }; } +/** + * 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. + */ +function parseRctCode(input: string): CdtMatch | null { + const lower = input.toLowerCase(); + const isRct = /\b(rct|root\s*canal)\b/.test(lower); + if (!isRct) return null; + + // Extract tooth number from "#NN" or bare number adjacent to "rct"/"root canal" + let toothNum: number | null = null; + const hashMatch = input.match(/#(\d{1,2})/); + if (hashMatch) { + toothNum = parseInt(hashMatch[1]!, 10); + } else { + // Fallback: bare number in input (e.g. "rct 29", "29 rct") + const numMatch = input.match(/\b(\d{1,2})\b/); + if (numMatch) toothNum = parseInt(numMatch[1]!, 10); + } + + if (!toothNum || toothNum < 1 || toothNum > 32) return null; + + let code: string; + if (RCT_ANTERIOR.has(toothNum)) { + code = "D3310"; + } else if (RCT_PREMOLAR.has(toothNum)) { + code = "D3320"; + } else if (RCT_MOLAR.has(toothNum)) { + code = "D3330"; + } else { + return null; + } + + const row = ALL_CODES.find((r) => r["Procedure Code"].toUpperCase() === code); + return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum) }; +} + /** * Score how well a set of query tokens matches a code's description tokens. * Each matched token contributes 1 point; shorter descriptions get a bonus @@ -347,10 +390,14 @@ export function lookupCdtCodes( const cleaned = name.trim().toLowerCase(); // Extract tooth# from "#NN" notation — applies to any procedure const toothInfo = extractToothSurface(name); + // Version of cleaned with tooth notation stripped, for alias matching + // e.g. "1 pa, #3" → "1 pa" so it hits the "1 pa" alias + const strippedCleaned = cleaned.replace(/,?\s*#\d{1,2}\b/g, "").replace(/\s+/g, " ").trim(); // 1. Custom alias exact match (highest priority) - if (customMap[cleaned]) { - const code = customMap[cleaned]!; + const customKey = customMap[cleaned] ? cleaned : customMap[strippedCleaned] ? strippedCleaned : null; + 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 }; } @@ -359,16 +406,21 @@ export function lookupCdtCodes( const compositeMatch = parseCompositeCode(name); if (compositeMatch) return compositeMatch; // already carries toothNumber/toothSurface + // 2b. RCT by tooth# — D3310/D3320/D3330 based on tooth position + const rctMatch = parseRctCode(name); + if (rctMatch) return rctMatch; + // 3. Hardcoded direct code map (short abbreviations / codes not in MH schedule) - if (DIRECT_CODE_MAP[cleaned]) { - const code = DIRECT_CODE_MAP[cleaned]!; + 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 }; } - // 4. Hardcoded alias + keyword search - const match = matchOne(name); - if (match) return { ...match, ...toothInfo }; + // 4. 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 }; return { code: null, diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index d23d10b9..2d583ca2 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -11,6 +11,7 @@ export type InternalChatIntent = | "claim_only" // submit claim for procedures (no eligibility check) | "navigate_claims" | "navigate_schedule" + | "navigate_eligibility" | "general"; export interface ChatClassification { @@ -76,6 +77,8 @@ Intents: Always extract appointmentDate when a date or "today" is mentioned - navigate_claims : open the claims page - navigate_schedule : open the appointments/schedule page +- navigate_eligibility : open the insurance eligibility page + e.g. "check mh", "check masshealth", "open eligibility", "go to eligibility", "check insurance" - general : anything else Rules: @@ -83,9 +86,16 @@ Rules: (e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes - For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces: e.g. "composite #29 O", "#8 MO", "composite #11 MOD" — keep the #number and surface letters together as one entry +- For RCT/root canal with a tooth number, preserve the tooth# in the entry: + e.g. "rct #29", "#14 root canal", "rct #6" — keep the #number with the procedure so the correct code can be selected +- 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 + 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. "2 pa #3 #14" → ["1 pa, #3", "2nd pa, #14"] - insuranceHint is only set when the user explicitly names an insurance in the message - Keep fallbackReply to 1-2 sentences -- For navigate intents, fallbackReply = "Opening the [page] page..." +- For navigate intents, fallbackReply = "Opening the [page] page..." (e.g. "Opening the eligibility page...") - appointmentDate applies to BOTH schedule_appointment AND claim_only/check_and_claim: always set it to today's date (${today}) when the user says "today", "this visit", or similar set it to the specified date when the user mentions a date (e.g. "05/15/2026") diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index b1940cdb..967940d5 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -26,6 +26,8 @@ export interface CdtResult { code: string | null; description: string; input: string; + toothNumber?: string; + toothSurface?: string; } export interface ChatResponse { @@ -181,6 +183,13 @@ export async function runInternalChatWorkflow( actionData: { url: "/appointments" }, }; } + if (intent === "navigate_eligibility") { + return { + reply: classification.fallbackReply ?? "Opening the eligibility page...", + action: "navigate", + actionData: { url: "/insurance-status" }, + }; + } // ── Find patient (record only, no eligibility) ────────────────────────────── @@ -451,6 +460,7 @@ async function handleCheckAndClaim( autoCheck: siteKeyToAutoCheck(siteKey), cdtResults, matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface })), + serviceDate: c.appointmentDate ?? null, }, }; } diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index e9c196a1..5d483c11 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -32,6 +32,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; +import { takeChatbotPendingFiles } from "@/lib/chatbotFileStore"; import { Claim, ClaimFileMeta, @@ -493,6 +494,13 @@ export function ClaimForm({ // Prefill service lines (and optional service date) from chatbot claim_only flow useEffect(() => { const raw = sessionStorage.getItem("chatbot_claim_prefill"); + const chatbotFiles = takeChatbotPendingFiles(); + if (!raw && chatbotFiles.length === 0) return; + + if (chatbotFiles.length > 0) { + setForm((prev) => ({ ...prev, uploadedFiles: chatbotFiles })); + } + if (!raw) return; try { const { codes, serviceDate } = JSON.parse(raw) as { diff --git a/apps/Frontend/src/components/claims/claims-recent-table.tsx b/apps/Frontend/src/components/claims/claims-recent-table.tsx index 8856d4dd..b3baff2e 100755 --- a/apps/Frontend/src/components/claims/claims-recent-table.tsx +++ b/apps/Frontend/src/components/claims/claims-recent-table.tsx @@ -316,6 +316,7 @@ export default function ClaimsRecentTable({ Claim No PreAuth No Patient Name + Service Date Submission Date Insurance Provider Member ID @@ -400,6 +401,11 @@ export default function ClaimsRecentTable({ + +
+ {claim.serviceDate ? formatDateToHumanReadable(claim.serviceDate) : "—"} +
+
{formatDateToHumanReadable(claim.createdAt!)} diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index 5028c422..aad825b7 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -10,12 +10,14 @@ import { Send, Loader2, RotateCcw, + Paperclip, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { useLocation } from "wouter"; import { cn } from "@/lib/utils"; import { apiRequest } from "@/lib/queryClient"; +import { setChatbotPendingFiles } from "@/lib/chatbotFileStore"; type Step = | "menu" @@ -59,6 +61,7 @@ interface CheckAndClaimData { siteKey: string; autoCheck: string; matchedCodes: { code: string; description: string }[]; + serviceDate?: string | null; } let msgCounter = 0; @@ -153,10 +156,12 @@ export function ChatbotButton() { serviceDate: string; appointmentId: number | null; } | null>(null); + const [pendingFiles, setPendingFiles] = useState([]); const [, setLocation] = useLocation(); const messagesEndRef = useRef(null); const pasteRef = useRef(null); const freeTextRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -204,6 +209,7 @@ export function ChatbotButton() { setApptSelectionData(null); setCdtClarificationData(null); setClaimReadyData(null); + setPendingFiles([]); }; // Full reset including message history and stored session @@ -306,6 +312,7 @@ export function ChatbotButton() { patientId: checkAndClaimData.patient?.id ?? null, memberId: checkAndClaimData.memberId, dob: checkAndClaimData.dob, + serviceDate: checkAndClaimData.serviceDate ?? null, }) ); prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck); @@ -329,7 +336,12 @@ export function ChatbotButton() { const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history, clientDate }); const data = await res.json(); - replaceLastMsg(data.reply ?? "Sorry, I couldn't process that."); + const claimActions = new Set(["claim_only_ready", "check_and_claim_ready", "need_appointment_selection"]); + const attachmentSuffix = + pendingFiles.length > 0 && claimActions.has(data.action) + ? ` (📎 ${pendingFiles.length} attachment${pendingFiles.length > 1 ? "s" : ""} will be included)` + : ""; + replaceLastMsg((data.reply ?? "Sorry, I couldn't process that.") + attachmentSuffix); if (data.action === "navigate" && data.actionData?.url) { setTimeout(() => { setLocation(data.actionData.url); setOpen(false); resetStep(); }, 800); @@ -365,6 +377,7 @@ export function ChatbotButton() { siteKey: data.actionData.siteKey, autoCheck: data.actionData.autoCheck, matchedCodes: data.actionData.matchedCodes ?? [], + serviceDate: data.actionData.serviceDate ?? null, }); setStep("check-and-claim-ready"); return; @@ -720,6 +733,7 @@ export function ChatbotButton() { siteKey: data.actionData.siteKey, autoCheck: data.actionData.autoCheck, matchedCodes: data.actionData.matchedCodes ?? [], + serviceDate: data.actionData.serviceDate ?? null, }); setStep("check-and-claim-ready"); } else if (data.action === "eligibility_id_ready" && data.actionData) { @@ -772,6 +786,7 @@ export function ChatbotButton() { autoSubmit: true, }) ); + setChatbotPendingFiles(pendingFiles); markJobStarted(); setTimeout(() => { setLocation(`/claims?appointmentId=${opt.appointmentId}`); @@ -826,6 +841,7 @@ export function ChatbotButton() { JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true }) ); } + setChatbotPendingFiles(pendingFiles); markJobStarted(); const url = appointmentId ? `/claims?appointmentId=${appointmentId}` @@ -891,6 +907,7 @@ export function ChatbotButton() { if (patient?.id && matchedCodes?.length > 0) { sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })); } + setChatbotPendingFiles(pendingFiles); const url = appointmentId ? `/claims?appointmentId=${appointmentId}` : `/claims?newPatient=${patient?.id}`; setTimeout(() => { setLocation(url); setOpen(false); resetStep(); }, 600); } else { @@ -915,7 +932,28 @@ export function ChatbotButton() { {/* Persistent free-text input */} {showFreeTextInput && (
-
+ {/* Attached file chips */} + {pendingFiles.length > 0 && ( +
+ {pendingFiles.map((f, i) => ( + + + {f.name} + + + ))} +
+ )} +