From 8e011c5a2982826bd16d28fdd398667a8123c0dd Mon Sep 17 00:00:00 2001 From: ff Date: Wed, 17 Jun 2026 01:21:51 -0400 Subject: [PATCH] feat: AI chatbot preauth intent, UnitedDH pre-auth improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add preauth intent to AI chatbot (classifier, workflow, frontend UI card) - Auto-prefill preauth form with CDT codes, service date, and mapped prices - Auto-trigger preauth Selenium handler by insurance siteKey (MH/Tufts/CCA/UnitedDH) - Default tentative service date to today+3 for preauth (user didn't pick a date) - Fix #number always means tooth number, distributes to all procedures in comma list - Fix bare "post"/"pos" → D2954 (was matching D2955 via keyword scorer) - UnitedDH pre-auth: fill procedure date with Ctrl+A to overwrite prefilled value - UnitedDH pre-auth: select Location "Summit Dental Care" in step1 (same as billing entity) - UnitedDH pre-auth: remove page refresh in step9 to preserve pre-auth number - UnitedDH pre-auth: wait for table rows before extracting pre-auth number - UnitedDH pre-auth/claim: explicit wait for Submit button after file upload (no sleep) Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/ai/cdt-lookup.ts | 2 + apps/Backend/src/ai/internal-chat-graph.ts | 12 ++- apps/Backend/src/ai/internal-chat-workflow.ts | 71 ++++++++++++++++++ .../src/components/claims/claim-form.tsx | 74 ++++++++++++++++++- .../src/components/layout/chatbot.tsx | 74 ++++++++++++++++++- apps/Frontend/src/pages/claims-page.tsx | 16 ++++ .../selenium_UnitedDH_claimSubmitWorker.py | 8 +- .../selenium_UnitedDH_preAuthWorker.py | 73 ++++++++++++++---- 8 files changed, 311 insertions(+), 19 deletions(-) diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index 97550864..ceaabfba 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -167,6 +167,8 @@ const DIRECT_CODE_MAP: Record = { // Core / post "core bu": "D2950", "p/c": "D2954", + "post": "D2954", + "pos": "D2954", "post core": "D2954", // Crowns "recement": "D2920", diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index f05b94e3..2f4cf692 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -9,6 +9,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) + | "preauth" // submit pre-authorization for procedures | "navigate_claims" | "navigate_schedule" | "navigate_eligibility" @@ -83,6 +84,11 @@ Intents: e.g. "claim perio exam, 2BW for John Smith" Use this when no eligibility check is requested — just billing/claiming services Always extract appointmentDate when a date or "today" is mentioned +- preauth : submit a pre-authorization request for procedures + e.g. "preauth rct, post, crown for John Smith" + e.g. "pre auth #20 rct, post, crown for Zhiyuan Chen" + e.g. "pre-auth D3320, D2952, D2740 for Maria" + Use this when the user says "preauth", "pre auth", "pre-auth", or "prior auth" - navigate_claims : open the claims page - navigate_schedule : open the appointments/schedule page - navigate_eligibility : open the insurance eligibility page @@ -99,8 +105,12 @@ Rules: Only include actual clinical procedures in procedureNames. - 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 +- #number always means a TOOTH number (never a case or pre-auth reference). When a single #number appears before a comma-separated list of procedures, apply it to EVERY procedure in the list. + e.g. "#20 rct, post, crown" → ["#20 rct", "#20 post", "#20 crown"] + e.g. "preauth #20 rct, pos, crown" → ["#20 rct", "#20 pos", "#20 crown"] + e.g. "#14 rct, buildup, crown" → ["#14 rct", "#14 buildup", "#14 crown"] - 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 + e.g. "rct #29", "#14 root canal", "rct #6", "#20 rct" — keep the #number with the procedure so the correct code can be selected - 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: diff --git a/apps/Backend/src/ai/internal-chat-workflow.ts b/apps/Backend/src/ai/internal-chat-workflow.ts index bb52d72c..e68a14a8 100644 --- a/apps/Backend/src/ai/internal-chat-workflow.ts +++ b/apps/Backend/src/ai/internal-chat-workflow.ts @@ -49,6 +49,7 @@ export interface ChatResponse { | "need_insurance_clarification" | "appointment_created" | "claim_only_ready" + | "preauth_ready" | "need_appointment_selection" | "need_cdt_clarification"; actionData?: Record; @@ -325,6 +326,12 @@ export async function runInternalChatWorkflow( return await handleClaimOnly(classification, storage, customAliases); } + // ── Pre-authorization ────────────────────────────────────────────────────── + + if (intent === "preauth") { + return await handlePreauth(classification, storage, customAliases); + } + // ── Schedule appointment ─────────────────────────────────────────────────── if (intent === "schedule_appointment") { @@ -692,6 +699,70 @@ async function handleClaimOnly( }; } +// ─── preauth ────────────────────────────────────────────────────────────────── + +async function handlePreauth( + c: ChatClassification, + storage: StorageLike, + customAliases: { phrase: string; cdtCode: string }[] +): Promise { + let patient: ResolvedPatient | null = null; + + if (c.memberId?.trim()) { + const existing = await findPatientByMemberId(c.memberId.trim(), c.dob, storage); + if (existing) patient = patientToResult(existing); + } else if (c.patientName?.trim()) { + const raw = await findPatientByName(c.patientName.trim(), storage); + if (raw) patient = patientToResult(raw); + } + + if (!patient) { + return { reply: "Please include a patient name or Member ID so I can look them up." }; + } + + const procedureNames = stripAttachmentRefs(c.procedureNames ?? []); + if (procedureNames.length === 0) { + return { reply: "Please specify which procedures to pre-authorize (e.g. rct, post, crown)." }; + } + + const cdtResults = 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(", ")}. What CDT code${phrases.length > 1 ? "s are" : " is"} ${phrases.length > 1 ? "they" : "it"}? (e.g. D3320)`, + action: "need_cdt_clarification", + actionData: { unknownPhrases: phrases, matchedSoFar: matched.map((r) => ({ code: r.code!, description: r.description })) }, + }; + } + + // Use explicit date from message; otherwise today+3 (pre-auth is for a future appointment) + let serviceDate: string = c.appointmentDate ?? (() => { + const d = new Date(); + d.setDate(d.getDate() + 3); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + })(); + + const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH"; + const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); + const [sy, sm, sd] = serviceDate.split("-"); + const dateLabel = `${sm}/${sd}/${sy}`; + + return { + reply: `Opening pre-auth for ${fullName} (tentative date ${dateLabel}): ${matched.map((r) => `${r.code} (${r.description})`).join(", ")}.`, + action: "preauth_ready", + actionData: { + patient, + siteKey, + serviceDate, + matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description, toothNumber: r.toothNumber, toothSurface: r.toothSurface, quad: r.quad })), + renderingProvider: c.renderingProvider ?? null, + }, + }; +} + // ─── schedule_appointment ───────────────────────────────────────────────────── const DEFAULT_STAFF_ID = 1; // Column A diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index a8b73075..f9891333 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -82,6 +82,10 @@ interface ClaimFormProps { autoSubmit?: boolean; /** When set, autoSubmit triggers this insurance's Selenium worker instead of MH */ autoSubmitSiteKey?: string; + /** When set, opens this tab by default (e.g. "preauth") */ + initialTab?: string; + /** When set, auto-triggers the matching preauth Selenium handler (siteKey value) */ + autoSubmitPreauth?: string; /** When true: form saves to AppointmentProcedure (Select Procedures flow), shows only Save button */ proceduresOnly?: boolean; onSubmit: (data: ClaimFormData) => Promise; @@ -107,6 +111,8 @@ export function ClaimForm({ appointmentId, autoSubmit, autoSubmitSiteKey, + initialTab, + autoSubmitPreauth, proceduresOnly = false, onHandleAppointmentSubmit, onHandleUpdatePatient, @@ -128,6 +134,7 @@ export function ClaimForm({ const [prefillDone, setPrefillDone] = useState(false); const autoSubmittedRef = useRef(false); + const preauthAutoSubmittedRef = useRef(false); // Read chatbot-requested rendering provider synchronously at mount (before any effects run) // so the npiProviders effect always sees it, even when the provider list is already cached. const [chatbotRenderingProvider] = useState(() => { @@ -568,6 +575,50 @@ export function ClaimForm({ } catch {} }, []); + // Prefill service lines and service date from chatbot preauth flow, then map prices + useEffect(() => { + const raw = sessionStorage.getItem("chatbot_preauth_prefill"); + if (!raw) return; + try { + const { codes, serviceDate, siteKey } = JSON.parse(raw) as { + codes: { code: string; description: string; toothNumber?: string; toothSurface?: string; quad?: string }[]; + serviceDate?: string; + siteKey?: string; + }; + sessionStorage.removeItem("chatbot_preauth_prefill"); + if (!codes?.length) return; + + if (serviceDate) { + try { + const d = parseLocalDate(serviceDate); + setServiceDateValue(d); + setServiceDate(formatLocalDate(d)); + } catch {} + } + + setForm((prev) => { + const date = serviceDate ? serviceDate : prev.serviceDate; + const insuranceSiteKey = siteKey ? siteKey.replace(/_/g, "").toLowerCase() : prev.insuranceSiteKey; + const updatedLines = [...prev.serviceLines]; + codes.forEach((c, i) => { + if (i < updatedLines.length) { + updatedLines[i] = { + ...updatedLines[i]!, + procedureCode: c.code, + procedureDate: date, + ...(c.toothNumber ? { toothNumber: c.toothNumber } : {}), + ...(c.toothSurface ? { toothSurface: c.toothSurface } : {}), + ...(c.quad ? { quad: c.quad } : {}), + }; + } + }); + const filled = { ...prev, insuranceSiteKey, serviceLines: updatedLines }; + return mapPricesForForm({ form: filled, patientDOB: "", insuranceSiteKey }); + }); + setPrefillDone(true); + } catch {} + }, []); + // Restore NPI provider from saved procedures when npiProviders list loads after 2b useEffect(() => { if (!savedProcNpiId || !npiProviders.length) return; @@ -1891,6 +1942,26 @@ export function ClaimForm({ } }, [autoSubmit, autoSubmitSiteKey, prefillDone, isFormReady]); + // Auto-trigger preauth Selenium handler when autoSubmitPreauth is set + useEffect(() => { + if (!autoSubmitPreauth) return; + if (!prefillDone) return; + if (!isFormReady) return; + if (preauthAutoSubmittedRef.current) return; + preauthAutoSubmittedRef.current = true; + + const key = autoSubmitPreauth.toLowerCase(); + if (key === "tufts_sco" || key === "tuftsco" || key === "tufts sco") { + handleTuftsSCOPreAuth(); + } else if (key === "cca") { + handleCCAPreAuth(); + } else if (key === "united_sco" || key === "unitedco" || key === "dentalhub") { + handleUnitedDHPreAuth(); + } else { + handleMHPreAuth(); + } + }, [autoSubmitPreauth, prefillDone, isFormReady]); + // overlay click handler (close when clicking backdrop) const onOverlayMouseDown = (e: React.MouseEvent) => { // only close if clicked the backdrop itself (not inner modal) @@ -1903,6 +1974,7 @@ export function ClaimForm({ return () => { // reset when ClaimForm unmounts (modal closes) autoSubmittedRef.current = false; + preauthAutoSubmittedRef.current = false; setPrefillDone(false); }; }, []); @@ -1913,7 +1985,7 @@ export function ClaimForm({ onMouseDown={onOverlayMouseDown} > - +
diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index 17cc8f18..d455f256 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -30,7 +30,8 @@ type Step = | "need-insurance-clarification" | "need-appointment-selection" | "need-cdt-clarification" - | "claim-ready"; + | "claim-ready" + | "preauth-ready"; interface Message { id: number; @@ -158,6 +159,13 @@ export function ChatbotButton() { appointmentId: number | null; renderingProvider: string | null; } | null>(null); + const [preauthReadyData, setPreauthReadyData] = useState<{ + patient: PatientResult | null; + matchedCodes: { code: string; description: string; toothNumber?: string }[]; + siteKey: string; + serviceDate: string; + renderingProvider: string | null; + } | null>(null); const [pendingFiles, setPendingFiles] = useState([]); const [, setLocation] = useLocation(); const messagesEndRef = useRef(null); @@ -211,6 +219,7 @@ export function ChatbotButton() { setApptSelectionData(null); setCdtClarificationData(null); setClaimReadyData(null); + setPreauthReadyData(null); setPendingFiles([]); }; @@ -471,6 +480,19 @@ export function ChatbotButton() { return; } + if (data.action === "preauth_ready" && data.actionData) { + const { patient, matchedCodes, siteKey, serviceDate, renderingProvider } = data.actionData; + setPreauthReadyData({ + patient: patient ?? null, + matchedCodes: matchedCodes ?? [], + siteKey, + serviceDate, + renderingProvider: renderingProvider ?? null, + }); + setStep("preauth-ready"); + return; + } + setStep("menu"); } catch { replaceLastMsg("Sorry, something went wrong. Please try again."); @@ -917,6 +939,56 @@ export function ChatbotButton() { ); })()} + {/* PreAuth confirmation card */} + {step === "preauth-ready" && preauthReadyData && (() => { + const [sy, sm, sd] = (preauthReadyData.serviceDate ?? "").split("-"); + const dateLabel = sy ? `${sm}/${sd}/${sy}` : preauthReadyData.serviceDate; + return ( +
+

Confirm Pre-Authorization

+ {preauthReadyData.patient && ( +

+ {preauthReadyData.patient.firstName} {preauthReadyData.patient.lastName} +

+ )} +

Tentative date: {dateLabel}

+ {preauthReadyData.matchedCodes.length > 0 && ( +
+ {preauthReadyData.matchedCodes.map((c) => ( +

+ {c.code}{c.toothNumber ? ` #${c.toothNumber}` : ""} — {c.description} +

+ ))} +
+ )} +
+ + +
+
+ ); + })()} + {/* CDT clarification — unknown procedure terms */} {step === "need-cdt-clarification" && cdtClarificationData && (
diff --git a/apps/Frontend/src/pages/claims-page.tsx b/apps/Frontend/src/pages/claims-page.tsx index 4e38b403..aa82fc04 100755 --- a/apps/Frontend/src/pages/claims-page.tsx +++ b/apps/Frontend/src/pages/claims-page.tsx @@ -40,6 +40,8 @@ export default function ClaimsPage() { const [isClaimFormOpen, setIsClaimFormOpen] = useState(false); const [selectedPatientId, setSelectedPatientId] = useState(null); const [chatbotAutoSubmitSiteKey, setChatbotAutoSubmitSiteKey] = useState(undefined); + const [chatbotInitialTab, setChatbotInitialTab] = useState(undefined); + const [chatbotAutoSubmitPreauthSiteKey, setChatbotAutoSubmitPreauthSiteKey] = useState(undefined); // for redirect from appointment page directly, then passing to claimform const [selectedAppointmentId, setSelectedAppointmentId] = useState< number | null @@ -258,6 +260,16 @@ export default function ClaimsPage() { } } catch {} + // Check if chatbot requested preauth tab + try { + const preauthRaw = sessionStorage.getItem("chatbot_preauth_prefill"); + if (preauthRaw) { + setChatbotInitialTab("preauth"); + const parsed = JSON.parse(preauthRaw); + if (parsed?.siteKey) setChatbotAutoSubmitPreauthSiteKey(parsed.siteKey); + } + } catch {} + handleNewClaim(id); clearUrlParams(["newPatient"]); }, [newPatient]); @@ -946,6 +958,8 @@ export default function ClaimsPage() { setSelectedPatientId(null); setSelectedAppointmentId(null); setChatbotAutoSubmitSiteKey(undefined); + setChatbotInitialTab(undefined); + setChatbotAutoSubmitPreauthSiteKey(undefined); setIsClaimFormOpen(false); clearUrlParams(["newPatient", "appointmentId"]); @@ -1078,6 +1092,8 @@ export default function ClaimsPage() { appointmentId={selectedAppointmentId ?? undefined} autoSubmit={mode === "direct" || !!chatbotAutoSubmitSiteKey} autoSubmitSiteKey={chatbotAutoSubmitSiteKey} + initialTab={chatbotInitialTab} + autoSubmitPreauth={chatbotAutoSubmitPreauthSiteKey} proceduresOnly={mode === "procedures"} onClose={closeClaim} onSubmit={handleClaimSubmit} diff --git a/apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py b/apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py index ef235412..e863256c 100644 --- a/apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py +++ b/apps/SeleniumService/selenium_UnitedDH_claimSubmitWorker.py @@ -1093,7 +1093,13 @@ class AutomationUnitedDHClaimSubmit: ) self.driver.execute_script("arguments[0].style.display='block';", file_input) file_input.send_keys(abs_path) - time.sleep(1.5) + WebDriverWait(self.driver, 60).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(@class,'btn-primary') and contains(normalize-space(text()),'Submit Claim')] | " + "//button[normalize-space(text())='Submit Claim'] | " + "//button[contains(normalize-space(.),'Submit Claim')]" + )) + ) print(f"[UnitedDH Claim] step7: Attached: {os.path.basename(abs_path)}") attached += 1 except Exception as e: diff --git a/apps/SeleniumService/selenium_UnitedDH_preAuthWorker.py b/apps/SeleniumService/selenium_UnitedDH_preAuthWorker.py index bf973cfa..191250b1 100644 --- a/apps/SeleniumService/selenium_UnitedDH_preAuthWorker.py +++ b/apps/SeleniumService/selenium_UnitedDH_preAuthWorker.py @@ -550,13 +550,28 @@ class AutomationUnitedDHPreAuth: 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"[UnitedDH PreAuth] step1: Selected Treatment Location: {option_text}") - location_selected = True + 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("[UnitedDH PreAuth] 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"[UnitedDH PreAuth] step1: Selected Treatment Location (fallback): {option_text}") + location_selected = True + except Exception: + print("[UnitedDH PreAuth] step1: No options available in Treatment Location dropdown") + time.sleep(1) except Exception as e: print(f"[UnitedDH PreAuth] step1: Treatment Location selection failed: {e}") @@ -693,6 +708,24 @@ class AutomationUnitedDHPreAuth: )) ) + # Fill procedure date from tentative service date. + # If tentative date == today, user did not change it — use today + 3 days instead. + if self.serviceDate: + try: + from datetime import date, timedelta + service_date = date.fromisoformat(self.serviceDate[:10]) + if service_date == date.today(): + service_date = service_date + timedelta(days=3) + print("[UnitedDH PreAuth] step3: Tentative date is today — using today + 3 days") + proc_date_str = service_date.strftime("%m/%d/%Y") + proc_input = self.driver.find_element(By.ID, "procedureDate_Back") + proc_input.click() + proc_input.send_keys(Keys.CONTROL, "a") + proc_input.send_keys(proc_date_str) + print(f"[UnitedDH PreAuth] step3: Procedure date entered: {proc_date_str}") + except Exception as e: + print(f"[UnitedDH PreAuth] step3: WARNING - Could not fill procedure date: {e}") + payer_selected = False try: payer_selectors = [ @@ -1019,7 +1052,15 @@ class AutomationUnitedDHPreAuth: ) self.driver.execute_script("arguments[0].removeAttribute('class');", file_input) file_input.send_keys(abs_path) - time.sleep(1.5) + WebDriverWait(self.driver, 60).until( + EC.element_to_be_clickable((By.XPATH, + "//button[contains(normalize-space(.),'Submit Authorization') or " + "contains(normalize-space(.),'Submit Pre-Auth') or " + "contains(normalize-space(.),'Submit Pre Authorization') or " + "contains(normalize-space(.),'Submit Pre-Authorization') or " + "contains(normalize-space(.),'Submit Claim')]" + )) + ) print(f"[UnitedDH PreAuth] step7: Attached: {os.path.basename(abs_path)}") attached += 1 except Exception as e: @@ -1085,15 +1126,17 @@ class AutomationUnitedDHPreAuth: lambda d: "status" in d.current_url.lower() or "history" in d.current_url.lower() or d.find_elements(By.XPATH, "//td | //th[contains(text(),'Reference')]") ) - time.sleep(4) + time.sleep(3) print(f"[UnitedDH PreAuth] step9: Status & History URL: {self.driver.current_url}") - self.driver.refresh() - print("[UnitedDH PreAuth] step9: Page refreshed — waiting for table to reload") - WebDriverWait(self.driver, 30).until( - EC.presence_of_element_located((By.XPATH, "//table//tr[td]")) - ) - time.sleep(4) + # Wait for table rows with actual data (not just headers) + try: + WebDriverWait(self.driver, 20).until( + EC.presence_of_element_located((By.XPATH, "//table//tr[td]")) + ) + time.sleep(2) + except Exception: + print("[UnitedDH PreAuth] step9: Table rows did not appear — proceeding with body scan") preauth_number = None