From cdda91f2b4dd827316139b514a7862bbe45e87fe Mon Sep 17 00:00:00 2001 From: Gitead Date: Mon, 29 Jun 2026 01:08:41 -0400 Subject: [PATCH] feat: add multi_claim intent for different procedures per patient + fix batch claim PDF race - Add multi_claim intent so AI correctly handles "claim X for patient A and Y for patient B" instead of applying all procedures to all patients (batch_claim) - Each patient carries their own matchedCodes in the queue - Fix batch claim PDF race condition: chatbot queue no longer advances in closeClaim(), instead advances after selenium PDF is downloaded (matching column claim behavior) - Align United SCO eligibility worker with claim worker: only fill subscriber ID + DOB, use treatmentLocation by ID instead of arrow-wrapper click Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/ai/internal-chat-graph.ts | 11 ++ apps/Backend/src/ai/internal-chat-workflow.ts | 113 ++++++++++++ .../src/components/layout/chatbot.tsx | 78 +++++++++ apps/Frontend/src/pages/claims-page.tsx | 163 ++++++++++-------- ...lenium_UnitedSCO_eligibilityCheckWorker.py | 55 +++--- 5 files changed, 324 insertions(+), 96 deletions(-) diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index 30bc6375..0af064f8 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -13,6 +13,7 @@ export type InternalChatIntent = | "find_patient" // look up patient record only | "schedule_appointment" // add patient to today's (or specified) schedule | "claim_only" // submit claim for procedures (no eligibility check) + | "multi_claim" // different procedures for different patients by name | "preauth" // submit pre-authorization for procedures | "navigate_claims" | "navigate_schedule" @@ -35,6 +36,8 @@ export interface ChatClassification { renderingProvider?: string; // raw name, e.g. "Kai Gao", "Dr. Smith" // --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) --- procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"] + // --- multi_claim (different procedures per patient) --- + claimGroups?: { patientName: string; procedureNames: string[] }[]; // --- scheduling --- appointmentDate?: string; // for schedule_appointment, YYYY-MM-DD (omit = today) appointmentTime?: string; // for schedule_appointment, HH:MM 24h (omit = 09:00) @@ -61,6 +64,7 @@ Respond ONLY with valid JSON (no markdown fences): "insuranceHint": "", "renderingProvider": "", "procedureNames": ["", ...], + "claimGroups": [{"patientName": "", "procedureNames": ["", ...]}, ...], "appointmentDate": "", "appointmentTime": "", "fallbackReply": "<1-2 sentence reply to show the user>" @@ -94,6 +98,13 @@ Intents: e.g. "perio exam, adult cleaning for Maria and John" Use this ONLY when procedures AND two or more patient names are given. Put each patient name into the "patientNames" array. Put procedure names in "procedureNames". +- multi_claim : user wants to claim DIFFERENT procedures for DIFFERENT patients identified by NAME + e.g. "claim #13 crown for flor and claim d5212 for bian" + e.g. "claim perio exam for Maria and claim adult prophy for John" + e.g. "claim D0120 for Jane and D1110 for Bob" + Use this when each patient has their OWN distinct set of procedures. + Put each patient+procedures group into the "claimGroups" array. + Do NOT use batch_claim for this — batch_claim is ONLY for the SAME procedures applied to ALL patients. - batch_check_and_claim : user provides MULTIPLE member IDs with DOBs AND wants to claim PROCEDURES for all of them e.g. "check mh for 100xxxx 10/10/1988 and 200xxxx 5/5/2000, and claim perio exam and adult prophy" e.g. "check 100xxxx, 10/10/1988 and 200xxxx, 5/5/2000 and claim D0120 D1110 for them" diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index c65de418..334511cd 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -47,6 +47,7 @@ export interface ChatResponse { | "eligibility_id_ready" | "batch_eligibility_ready" | "batch_claim_ready" + | "multi_claim_ready" | "batch_check_and_claim_ready" | "check_and_claim_ready" | "need_insurance_clarification" @@ -345,6 +346,12 @@ export async function runInternalChatWorkflow( return await handleBatchClaim(classification, storage, customAliases); } + // ── Multi claim (different procedures for different patients) ───────────── + + if (intent === "multi_claim") { + return await handleMultiClaim(classification, storage, customAliases); + } + // ── Claim only (no eligibility check) ───────────────────────────────────── if (intent === "claim_only") { @@ -810,6 +817,112 @@ async function handleBatchClaim( }; } +// ─── multi_claim (different procedures per patient) ───────────────────────── + +async function handleMultiClaim( + c: ChatClassification, + storage: StorageLike, + customAliases: { phrase: string; cdtCode: string }[] +): Promise { + const groups = c.claimGroups ?? []; + if (groups.length < 2) { + if (groups.length === 1) { + return await handleClaimOnly( + { ...c, patientName: groups[0]!.patientName, procedureNames: groups[0]!.procedureNames, intent: "claim_only" }, + storage, + customAliases + ); + } + return { reply: "Please specify at least two patients with their procedures." }; + } + + const queue: { + patient: ResolvedPatient; + siteKey: string; + serviceDate: string | null; + appointmentId: number | null; + matchedCodes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[]; + }[] = []; + const notFound: string[] = []; + + for (const group of groups) { + const name = group.patientName?.trim(); + if (!name) continue; + + const procedureNames = stripAttachmentRefs(group.procedureNames ?? []); + if (procedureNames.length === 0) continue; + + const cdtResults: CdtResult[] = lookupCdtCodes(procedureNames, customAliases); + const matched = cdtResults.filter((r) => r.code !== null); + const unmatched = cdtResults.filter((r) => r.code === null); + + if (unmatched.length > 0) { + const phrases = unmatched.map((r) => r.input); + return { + reply: `I don't recognize ${phrases.map((p) => `"${p}"`).join(", ")} for ${name}. 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 })) }, + }; + } + + const raw = await findPatientByName(name, storage); + if (!raw) { + notFound.push(name); + continue; + } + 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 siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH"; + + let serviceDate: string | null = c.appointmentDate ?? null; + if (!serviceDate) { + const now = new Date(); + serviceDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + } + + queue.push({ + patient, + siteKey, + serviceDate, + appointmentId: null, + matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), + }); + } + + if (notFound.length > 0 && queue.length === 0) { + return { reply: `Could not find any patients matching: ${notFound.join(", ")}. Please check the spelling.` }; + } + + const labels = queue.map((r) => { + const name = `${r.patient.firstName ?? ""} ${r.patient.lastName ?? ""}`.trim(); + const codes = r.matchedCodes.map((c) => `${c.code}`).join(", "); + return `${name} (${codes})`; + }); + let reply = `Ready to claim for ${queue.length} patients: ${labels.join("; ")}.`; + if (notFound.length > 0) { + reply += ` Could not find: ${notFound.join(", ")}.`; + } + + return { + reply, + action: "multi_claim_ready", + actionData: { + queue: queue.map((r) => ({ + patient: r.patient, + siteKey: r.siteKey, + serviceDate: r.serviceDate, + appointmentId: r.appointmentId, + matchedCodes: r.matchedCodes, + })), + renderingProvider: c.renderingProvider ?? null, + }, + }; +} + // ─── check_and_claim ───────────────────────────────────────────────────────── async function handleCheckAndClaim( diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index b7c2090e..281fffe8 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -33,6 +33,7 @@ type Step = | "need-cdt-clarification" | "claim-ready" | "batch-claim-ready" + | "multi-claim-ready" | "batch-check-and-claim-ready" | "preauth-ready"; @@ -589,6 +590,16 @@ export function ChatbotButton() { return; } + if (data.action === "multi_claim_ready" && data.actionData) { + setBatchClaimData({ + queue: data.actionData.queue ?? [], + matchedCodes: [], + renderingProvider: data.actionData.renderingProvider ?? null, + }); + setStep("multi-claim-ready"); + return; + } + if (data.action === "claim_only_ready" && data.actionData) { const { patient, matchedCodes, siteKey, serviceDate, appointmentId, renderingProvider } = data.actionData; setClaimReadyData({ @@ -638,6 +649,7 @@ export function ChatbotButton() { step === "batch-eligibility-ready" || step === "check-and-claim-ready" || step === "batch-claim-ready" || + step === "multi-claim-ready" || step === "batch-check-and-claim-ready" || step === "need-insurance-clarification" || step === "need-appointment-selection"; @@ -1193,6 +1205,72 @@ export function ChatbotButton() { )} + {/* Multi claim ready — different procedures per patient */} + {step === "multi-claim-ready" && batchClaimData && ( +
+

+ Claim for {batchClaimData.queue.length} patients: +

+ {batchClaimData.queue.map((item: any, i: number) => ( +
+

+ {i + 1}. {item.patient.firstName} {item.patient.lastName} +

+ {item.matchedCodes?.length > 0 && ( +
+ {item.matchedCodes.map((c: any) => ( +

+ {c.code} — {c.description} +

+ ))} +
+ )} +
+ ))} +
+ + +
+
+ )} + {/* Claim ready — confirm before submitting */} {step === "claim-ready" && claimReadyData && (() => { const [sy, sm, sd] = (claimReadyData.serviceDate ?? "").split("-"); diff --git a/apps/Frontend/src/pages/claims-page.tsx b/apps/Frontend/src/pages/claims-page.tsx index 11591721..f58ee752 100755 --- a/apps/Frontend/src/pages/claims-page.tsx +++ b/apps/Frontend/src/pages/claims-page.tsx @@ -143,6 +143,7 @@ export default function ClaimsPage() { // CCA result: pdfFileId is already saved by the processor — open preview directly if (jobResult.pdfFileId) { advanceAiClaimQueue(); + advanceChatbotClaimQueue(); setPreviewPdfId(jobResult.pdfFileId); setPreviewFallbackFilename(jobResult.pdfFilename ?? `cca_claim_${jobResult.claimNumber ?? "unknown"}.pdf`); setPreviewOpen(true); @@ -333,6 +334,82 @@ export default function ClaimsPage() { } }; + // Advance the chatbot_claim_queue / chatbot_check_claim_queue after selenium PDF is saved. + // NOT called from closeClaim — called only after the selenium job completes and PDF is downloaded. + const advanceChatbotClaimQueue = () => { + try { + const raw = sessionStorage.getItem("chatbot_check_claim_queue"); + if (raw) { + const parsed = JSON.parse(raw); + const remaining = parsed?.remaining as any[] | undefined; + const matchedCodes = parsed?.matchedCodes ?? []; + const renderingProvider = parsed?.renderingProvider ?? null; + if (remaining && remaining.length > 0) { + const [next, ...rest] = remaining; + if (rest.length > 0) { + sessionStorage.setItem("chatbot_check_claim_queue", JSON.stringify({ remaining: rest, matchedCodes, renderingProvider })); + } else { + sessionStorage.removeItem("chatbot_check_claim_queue"); + } + sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({ + codes: matchedCodes, + siteKey: next.siteKey, + patientId: next.patient?.id ?? null, + memberId: next.memberId, + dob: next.dob, + serviceDate: null, + renderingProvider, + })); + sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ + memberId: next.memberId, + dob: next.dob, + autoCheck: next.autoCheck, + })); + setTimeout(() => { + window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); + setWouterLocation("/insurance-status"); + }, 500); + return; + } + sessionStorage.removeItem("chatbot_check_claim_queue"); + } + } catch {} + + try { + const raw = sessionStorage.getItem("chatbot_claim_queue"); + if (raw) { + const parsed = JSON.parse(raw); + const remaining = parsed?.remaining as any[] | undefined; + const sharedMatchedCodes = parsed?.matchedCodes ?? []; + const renderingProvider = parsed?.renderingProvider ?? null; + if (remaining && remaining.length > 0) { + const [next, ...rest] = remaining; + if (rest.length > 0) { + sessionStorage.setItem("chatbot_claim_queue", JSON.stringify({ remaining: rest, matchedCodes: sharedMatchedCodes, renderingProvider })); + } else { + sessionStorage.removeItem("chatbot_claim_queue"); + } + const codes = next.matchedCodes?.length > 0 ? next.matchedCodes : sharedMatchedCodes; + if (next.patient?.id && codes.length > 0) { + sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ + codes, + siteKey: next.siteKey, + serviceDate: next.serviceDate, + autoSubmit: true, + renderingProvider, + dob: next.patient.dateOfBirth ?? null, + })); + } + setTimeout(() => { + setWouterLocation(`/claims?newPatient=${next.patient.id}`); + }, 500); + return; + } + sessionStorage.removeItem("chatbot_claim_queue"); + } + } catch {} + }; + // Advance the ai_claim_queue by removing the first item (current patient). // Called only on successful submission so that cancel leaves the queue intact. const advanceAiClaimQueue = () => { @@ -370,7 +447,10 @@ export default function ClaimsPage() { const data = await res.json(); if (!res.ok) throw new Error(data?.message || "Failed to save claim"); queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE }); - if (!isDraft) advanceAiClaimQueue(); + if (!isDraft) { + advanceAiClaimQueue(); + advanceChatbotClaimQueue(); + } return data; }; @@ -884,7 +964,10 @@ export default function ClaimsPage() { duration: isPreAuth ? 10000 : 5000, }); - if (!isPreAuth) advanceAiClaimQueue(); + if (!isPreAuth) { + advanceAiClaimQueue(); + advanceChatbotClaimQueue(); + } // Pop up the final PDF so the user doesn't need to navigate to Documents if (result.pdfFileId) { @@ -933,78 +1016,10 @@ export default function ClaimsPage() { } } catch {} - // If a chatbot batch check+claim queue is pending, navigate to eligibility for the next patient - try { - const raw = sessionStorage.getItem("chatbot_check_claim_queue"); - if (raw) { - const parsed = JSON.parse(raw); - const remaining = parsed?.remaining as any[] | undefined; - const matchedCodes = parsed?.matchedCodes ?? []; - const renderingProvider = parsed?.renderingProvider ?? null; - if (remaining && remaining.length > 0) { - const [next, ...rest] = remaining; - if (rest.length > 0) { - sessionStorage.setItem("chatbot_check_claim_queue", JSON.stringify({ remaining: rest, matchedCodes, renderingProvider })); - } else { - sessionStorage.removeItem("chatbot_check_claim_queue"); - } - sessionStorage.setItem("chatbot_claim_codes", JSON.stringify({ - codes: matchedCodes, - siteKey: next.siteKey, - patientId: next.patient?.id ?? null, - memberId: next.memberId, - dob: next.dob, - serviceDate: null, - renderingProvider, - })); - sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ - memberId: next.memberId, - dob: next.dob, - autoCheck: next.autoCheck, - })); - setTimeout(() => { - window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); - setWouterLocation("/insurance-status"); - }, 500); - return; - } - sessionStorage.removeItem("chatbot_check_claim_queue"); - } - } catch {} - - // If a chatbot batch claim queue is pending, open the next patient - try { - const raw = sessionStorage.getItem("chatbot_claim_queue"); - if (raw) { - const parsed = JSON.parse(raw); - const remaining = parsed?.remaining as any[] | undefined; - const matchedCodes = parsed?.matchedCodes ?? []; - const renderingProvider = parsed?.renderingProvider ?? null; - if (remaining && remaining.length > 0) { - const [next, ...rest] = remaining; - if (rest.length > 0) { - sessionStorage.setItem("chatbot_claim_queue", JSON.stringify({ remaining: rest, matchedCodes, renderingProvider })); - } else { - sessionStorage.removeItem("chatbot_claim_queue"); - } - if (next.patient?.id && matchedCodes.length > 0) { - sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ - codes: matchedCodes, - siteKey: next.siteKey, - serviceDate: next.serviceDate, - autoSubmit: true, - renderingProvider, - dob: next.patient.dateOfBirth ?? null, - })); - } - setTimeout(() => { - setWouterLocation(`/claims?newPatient=${next.patient.id}`); - }, 500); - return; - } - sessionStorage.removeItem("chatbot_claim_queue"); - } - } catch {} + // Chatbot batch queues (chatbot_claim_queue, chatbot_check_claim_queue) are NOT + // advanced here — they are advanced in advanceChatbotClaimQueue() which is called + // only after the selenium job completes and PDF is downloaded. This prevents the + // next patient's claim from starting before the current patient's PDF is saved. }; // Pre Auth section diff --git a/apps/SeleniumService/selenium_UnitedSCO_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_UnitedSCO_eligibilityCheckWorker.py index c92e8a50..2bff2da8 100644 --- a/apps/SeleniumService/selenium_UnitedSCO_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_UnitedSCO_eligibilityCheckWorker.py @@ -423,19 +423,19 @@ class AutomationUnitedSCOEligibilityCheck: def step1(self): """ Navigate to Eligibility page and fill the Patient Information form. - - Workflow based on actual DOM testing: + + Workflow: 1. Navigate directly to eligibility page - 2. Fill First Name (id='firstName_Back'), Last Name (id='lastName_Back'), DOB (id='dateOfBirth_Back') + 2. Fill Subscriber ID and DOB 3. Select Payer: "UnitedHealthcare Massachusetts" from ng-select dropdown 4. Click Continue - 5. Handle Practitioner & Location page - click paymentGroupId dropdown, select Summit Dental Care + 5. Handle Practitioner & Location page - select Treatment Location and Billing Entity 6. Click Continue again """ from selenium.webdriver.common.action_chains import ActionChains try: - print(f"[UnitedSCO step1] Starting eligibility search for: {self.firstName} {self.lastName}, DOB: {self.dateOfBirth}") + print(f"[UnitedSCO step1] Starting eligibility search for: memberId={self.memberId}, DOB: {self.dateOfBirth}") # Navigate directly to eligibility page print("[UnitedSCO step1] Navigating to eligibility page...") @@ -644,23 +644,34 @@ class AutomationUnitedSCOEligibilityCheck: print("[UnitedSCO step1] Selecting Treatment Location...") location_selected = False try: - location_ng = WebDriverWait(self.driver, 10).until( - EC.element_to_be_clickable((By.XPATH, - "//label[@for='treatmentLocation']/following-sibling::ng-select | " - "//label[@for='treatmentLocation']/..//ng-select" - )) - ) - # Center in viewport so panel opens downward instead of upward - self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", location_ng) - arrow = location_ng.find_element(By.CSS_SELECTOR, ".ng-arrow-wrapper") - arrow.click() - first_option = WebDriverWait(self.driver, 5).until( - EC.element_to_be_clickable((By.CSS_SELECTOR, ".ng-dropdown-panel .ng-option")) - ) - option_text = first_option.text.strip() - first_option.click() - print(f"[UnitedSCO step1] Selected Treatment Location: {option_text}") - location_selected = True + location_input = self.driver.find_element(By.ID, "treatmentLocation") + if location_input.is_displayed(): + self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", location_input) + location_input.click() + print("[UnitedSCO step1] Clicked Treatment Location dropdown") + time.sleep(1) + try: + summit_option = WebDriverWait(self.driver, 5).until( + EC.element_to_be_clickable((By.XPATH, + "//ng-dropdown-panel//div[contains(@class,'ng-option') and contains(.,'Summit Dental Care')]" + )) + ) + summit_option.click() + print("[UnitedSCO step1] Selected Treatment Location: Summit Dental Care") + location_selected = True + except TimeoutException: + try: + first_option = self.driver.find_element(By.XPATH, + "//ng-dropdown-panel//div[contains(@class,'ng-option')]" + ) + option_text = first_option.text.strip() + first_option.click() + print(f"[UnitedSCO step1] Selected Treatment Location (fallback): {option_text}") + location_selected = True + except Exception: + print("[UnitedSCO step1] No options available in Treatment Location dropdown") + ActionChains(self.driver).send_keys(Keys.ESCAPE).perform() + time.sleep(1) except Exception as e: print(f"[UnitedSCO step1] Treatment Location selection failed: {e}")