From 143319909feb6878059caa9dbc9289c664a1587f Mon Sep 17 00:00:00 2001 From: Gitead Date: Mon, 12 Oct 2026 01:27:28 -0400 Subject: [PATCH] fix: AI claims/preauths keep all codes past 10; reuse CCA/DentalHub Chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Claim form: AI claim/preauth prefill adds rows beyond the initial 10 instead of dropping codes 11+ - CDT lookup: "D6010 #5, 9, 11, 12" expands to one line per tooth, and the chat graph rebuilds such lists from the message if the LLM drops items - Eligibility: match an existing patient by name+DOB when the insurance ID changed (e.g. MassHealth → CCA) instead of creating a duplicate patient - CCA PreAuth: services grid scrolls to reach rows 11+ - CCA and Tufts SCO PreAuth: tentative service date = today + 3 days - CCA (eligibility/claim/preauth) and DentalHub (claim/preauth): keep Chrome open, blank and minimized, after a job so the next job reuses it Co-Authored-By: Claude Opus 5.5 --- apps/Backend/src/ai/cdt-lookup.ts | 30 +++++++++- apps/Backend/src/ai/internal-chat-graph.ts | 20 +++++++ apps/Backend/src/queue/processors/_shared.ts | 10 ++++ apps/Backend/src/storage/patients-storage.ts | 16 +++++ .../src/components/claims/claim-form.tsx | 60 ++++++++++++------- apps/SeleniumService/helpers_cca_claim.py | 21 +++++-- .../helpers_cca_eligibility.py | 21 +++++-- apps/SeleniumService/helpers_cca_preauth.py | 21 +++++-- .../SeleniumService/helpers_uniteddh_claim.py | 13 ++-- .../helpers_uniteddh_preauth.py | 13 ++-- .../selenium_CCA_eligibilityCheckWorker.py | 9 ++- .../selenium_CCA_preAuthWorker.py | 40 +++++++++---- .../selenium_TuftsSCO_preAuthWorker.py | 7 ++- 13 files changed, 218 insertions(+), 63 deletions(-) diff --git a/apps/Backend/src/ai/cdt-lookup.ts b/apps/Backend/src/ai/cdt-lookup.ts index 81c2963a..85cc8ace 100644 --- a/apps/Backend/src/ai/cdt-lookup.ts +++ b/apps/Backend/src/ai/cdt-lookup.ts @@ -630,6 +630,27 @@ function expandCompositeLists(procedureNames: string[]): string[] { return out; } +// Explicit code + tooth list with no surfaces, e.g. "D6010 #5, 9 , 11, 12" or "D6057 #5 #9 and 11" +const CODE_TOOTH_LIST_RE = + /\b(D\d{4})\s*#\s*(\d{1,2}(?:\s*(?:,|&|\band\b)?\s*#?\s*\d{1,2})*)\b(?!\s*[OMDBLFIV]{1,5}\b)/gi; + +/** + * Split explicit CDT codes followed by a tooth list into one entry per tooth. + * Returns null when the text has no code with more than one tooth. + * e.g. "D6010 #5, 9, 11, 12 D6057 #5, 9" → ["D6010 #5", "D6010 #9", "D6010 #11", "D6010 #12", "D6057 #5", "D6057 #9"] + */ +export function expandCodeToothLists(text: string): string[] | null { + const out: string[] = []; + let multi = false; + for (const m of text.matchAll(CODE_TOOTH_LIST_RE)) { + const code = m[1]!.toUpperCase(); + const teeth = m[2]!.match(/\d{1,2}/g) ?? []; + if (teeth.length > 1) multi = true; + for (const t of teeth) out.push(`${code} #${t}`); + } + return multi ? out : null; +} + /** * Map an array of free-text procedure names to CDT codes. * customAliases (from DB) are checked before the hardcoded ALIAS_MAP. @@ -648,7 +669,14 @@ export function lookupCdtCodes( // Pontics with no stated material — re-coded below to match the crowns const inferredPontics: CdtMatch[] = []; - const results = expandCompositeLists(procedureNames).flatMap((name): (CdtMatch | { code: null; description: string; input: string })[] => { + // Only split an entry that is nothing but code + tooth lists, so no other text is lost + const expandedNames = procedureNames.flatMap((n) => { + const split = expandCodeToothLists(n); + const rest = n.replace(CODE_TOOTH_LIST_RE, "").replace(/[\s,;&]|\band\b/gi, ""); + return split && rest === "" ? split : [n]; + }); + + const results = expandCompositeLists(expandedNames).flatMap((name): (CdtMatch | { code: null; description: string; input: string })[] => { const cleaned = name.trim().toLowerCase(); // If the LLM emitted a bare tooth-number token like "# 32" or "#32" with no procedure, diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index a25d7b88..fcc0b6fe 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -1,4 +1,5 @@ import { getLlm, type AiProvider } from "./llm-factory"; +import { expandCodeToothLists } from "./cdt-lookup"; // ─── Intent types ───────────────────────────────────────────────────────────── @@ -254,6 +255,24 @@ function restoreBarePaCount(message: string, names: string[]): string[] { return kept; } +// "D6010 #5, 9, 11, 12 D6057 #5, 9, 11, 12" — the LLM can drop or merge items when +// splitting long tooth lists, so rebuild those entries from the message itself. +export function restoreCodeToothLists(message: string, names: string[]): string[] { + const expanded = expandCodeToothLists(message); + if (!expanded) return names; + const codes = new Set(expanded.map((e) => e.split(" ")[0]!)); + const isCovered = (n: string) => { + const s = n.trim(); + if (/^#?\s*\d{1,2}$/.test(s)) return true; // stray tooth number from the split + const m = s.match(/^(D\d{4})\b/i); + return !!m && codes.has(m[1]!.toUpperCase()); + }; + const first = names.findIndex(isCovered); + const kept = names.filter((n) => !isCovered(n)); + kept.splice(first === -1 ? kept.length : first, 0, ...expanded); + return kept; +} + function parseBareIdDob(message: string): { memberId: string; dob: string } | null { const text = message.trim(); const dobPattern = /(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{4})/; @@ -347,6 +366,7 @@ export async function classifyInternalChat( const parsed = JSON.parse(jsonStr) as ChatClassification; if (!parsed.intent || !parsed.fallbackReply) return fallback; if (parsed.procedureNames) parsed.procedureNames = restoreBarePaCount(message, parsed.procedureNames); + if (parsed.procedureNames) parsed.procedureNames = restoreCodeToothLists(message, parsed.procedureNames); return parsed; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/apps/Backend/src/queue/processors/_shared.ts b/apps/Backend/src/queue/processors/_shared.ts index 6d966700..7e18aa6c 100644 --- a/apps/Backend/src/queue/processors/_shared.ts +++ b/apps/Backend/src/queue/processors/_shared.ts @@ -125,6 +125,15 @@ export async function createOrUpdatePatientByInsuranceId(options: { console.log(`[createOrUpdatePatient] id-only fallback (no DOB on record): found id=${patient.id}`); } } + if (!patient && incomingFirst && incomingLast) { + // Same person whose insurance changed (e.g. MassHealth → CCA): match by + // name+DOB and move them to the new insuranceId instead of duplicating. + const byNameDob = await storage.getPatientByNameAndDob(incomingFirst, incomingLast, dobDate); + if (byNameDob) { + patient = byNameDob; + console.log(`[createOrUpdatePatient] name+DOB fallback: found id=${patient.id} (insuranceId "${patient.insuranceId}" → "${normalizedId}")`); + } + } } else { // No DOB supplied — fall back to insuranceId-only (legacy / non-family plans) patient = await storage.getPatientByInsuranceId(normalizedId); @@ -138,6 +147,7 @@ export async function createOrUpdatePatientByInsuranceId(options: { if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast) updates.lastName = incomingLast; if (dobDate && !patient.dateOfBirth) updates.dateOfBirth = dobDate; + if (String(patient.insuranceId ?? "") !== normalizedId) updates.insuranceId = normalizedId; if (Object.keys(updates).length > 0) { console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, updates); await storage.updatePatient(patient.id, updates); diff --git a/apps/Backend/src/storage/patients-storage.ts b/apps/Backend/src/storage/patients-storage.ts index eb67ba8d..a3a05572 100755 --- a/apps/Backend/src/storage/patients-storage.ts +++ b/apps/Backend/src/storage/patients-storage.ts @@ -12,6 +12,7 @@ export interface IStorage { getPatientByInsuranceId(insuranceId: string): Promise; getPatientByInsuranceIdAndDob(insuranceId: string, dob: Date): Promise; getPatientByInsuranceIdNoDob(insuranceId: string): Promise; + getPatientByNameAndDob(firstName: string, lastName: string, dob: Date): Promise; getAllPatients(): Promise; getRecentPatients(limit: number, offset: number): Promise; getPatientsByIds(ids: number[]): Promise; @@ -81,6 +82,21 @@ export const patientsStorage: IStorage = { }); }, + async getPatientByNameAndDob(firstName: string, lastName: string, dob: Date): Promise { + const startOfDay = new Date(dob); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(dob); + endOfDay.setHours(23, 59, 59, 999); + return db.patient.findFirst({ + where: { + firstName: { equals: firstName, mode: "insensitive" }, + lastName: { equals: lastName, mode: "insensitive" }, + dateOfBirth: { gte: startOfDay, lte: endOfDay }, + }, + orderBy: { updatedAt: "desc" }, + }); + }, + async getRecentPatients(limit: number, offset: number): Promise { return db.patient.findMany({ skip: offset, diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index bbc4d913..fad1821d 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -576,16 +576,26 @@ export function ClaimForm({ const date = serviceDate ? serviceDate : prev.serviceDate; 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 } : {}), - }; - } + // The form starts with 10 blank rows — add rows for codes beyond that + const base = updatedLines[i] ?? { + procedureCode: "", + procedureDate: date, + quad: "", + arch: "", + toothNumber: "", + toothSurface: "", + totalBilled: new Decimal(0), + totalAdjusted: new Decimal(0), + totalPaid: new Decimal(0), + }; + updatedLines[i] = { + ...base, + procedureCode: c.code, + procedureDate: date, + ...(c.toothNumber ? { toothNumber: c.toothNumber } : {}), + ...(c.toothSurface ? { toothSurface: c.toothSurface } : {}), + ...(c.quad ? { quad: c.quad } : {}), + }; }); return { ...prev, serviceLines: updatedLines }; }); @@ -620,16 +630,26 @@ export function ClaimForm({ 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 } : {}), - }; - } + // The form starts with 10 blank rows — add rows for codes beyond that + const base = updatedLines[i] ?? { + procedureCode: "", + procedureDate: date, + quad: "", + arch: "", + toothNumber: "", + toothSurface: "", + totalBilled: new Decimal(0), + totalAdjusted: new Decimal(0), + totalPaid: new Decimal(0), + }; + updatedLines[i] = { + ...base, + 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({ diff --git a/apps/SeleniumService/helpers_cca_claim.py b/apps/SeleniumService/helpers_cca_claim.py index 5a559a23..2f54aa39 100644 --- a/apps/SeleniumService/helpers_cca_claim.py +++ b/apps/SeleniumService/helpers_cca_claim.py @@ -50,11 +50,22 @@ def _close_browser(bot): bm.save_cookies() except Exception: pass - try: - bm.quit_driver() - print("[CCA Claim] Browser closed") - except Exception: - pass + # Keep Chrome open (blank + minimized) so the next CCA job reuses it — faster + # than starting a new browser. get_driver() recreates it if it was closed. + if bot and bot.driver: + try: + bot.driver.get("about:blank") + except Exception: + pass + try: + bot.driver.minimize_window() + print("[CCA Claim] Browser kept open (minimized)") + except Exception: + try: + bot.driver.set_window_position(-10000, -10000) + print("[CCA Claim] Browser kept open (moved off-screen)") + except Exception: + pass except Exception as e: print(f"[CCA Claim] Could not close browser: {e}") diff --git a/apps/SeleniumService/helpers_cca_eligibility.py b/apps/SeleniumService/helpers_cca_eligibility.py index fcca85d1..3514cfad 100644 --- a/apps/SeleniumService/helpers_cca_eligibility.py +++ b/apps/SeleniumService/helpers_cca_eligibility.py @@ -51,11 +51,22 @@ def _close_browser(bot): bm.save_cookies() except Exception: pass - try: - bm.quit_driver() - print("[CCA] Browser closed") - except Exception: - pass + # Keep Chrome open (blank + minimized) so the next CCA job reuses it — faster + # than starting a new browser. get_driver() recreates it if it was closed. + if bot and bot.driver: + try: + bot.driver.get("about:blank") + except Exception: + pass + try: + bot.driver.minimize_window() + print("[CCA] Browser kept open (minimized)") + except Exception: + try: + bot.driver.set_window_position(-10000, -10000) + print("[CCA] Browser kept open (moved off-screen)") + except Exception: + pass except Exception as e: print(f"[CCA] Could not close browser: {e}") diff --git a/apps/SeleniumService/helpers_cca_preauth.py b/apps/SeleniumService/helpers_cca_preauth.py index 2695d1a2..f6ab3491 100644 --- a/apps/SeleniumService/helpers_cca_preauth.py +++ b/apps/SeleniumService/helpers_cca_preauth.py @@ -50,11 +50,22 @@ def _close_browser(bot): bm.save_cookies() except Exception: pass - try: - bm.quit_driver() - print("[CCA PreAuth] Browser closed") - except Exception: - pass + # Keep Chrome open (blank + minimized) so the next CCA job reuses it — faster + # than starting a new browser. get_driver() recreates it if it was closed. + if bot and bot.driver: + try: + bot.driver.get("about:blank") + except Exception: + pass + try: + bot.driver.minimize_window() + print("[CCA PreAuth] Browser kept open (minimized)") + except Exception: + try: + bot.driver.set_window_position(-10000, -10000) + print("[CCA PreAuth] Browser kept open (moved off-screen)") + except Exception: + pass except Exception as e: print(f"[CCA PreAuth] Could not close browser: {e}") diff --git a/apps/SeleniumService/helpers_uniteddh_claim.py b/apps/SeleniumService/helpers_uniteddh_claim.py index 7c33fc1f..7c5502a4 100644 --- a/apps/SeleniumService/helpers_uniteddh_claim.py +++ b/apps/SeleniumService/helpers_uniteddh_claim.py @@ -297,13 +297,14 @@ async def start_uniteddh_claim_run(sid: str, data: dict, url: str): s["result"] = result s["message"] = "completed" - # Close browser window (session preserved in profile via UnitedSCO browser manager) + # Keep Chrome open (blank + minimized) so the next job reuses it — faster than + # starting a new browser. The browser manager recreates it if it was closed. try: - from unitedsco_browser_manager import get_browser_manager as _gbm - _gbm().quit_driver() - print("[UnitedDH Claim] Browser closed - session preserved in profile") - except Exception as close_err: - print(f"[UnitedDH Claim] Could not close browser (non-fatal): {close_err}") + bot.driver.get("about:blank") + bot.driver.minimize_window() + print("[UnitedDH Claim] Browser kept open (minimized)") + except Exception as hide_err: + print(f"[UnitedDH Claim] Could not minimize browser (non-fatal): {hide_err}") asyncio.create_task(_remove_session_later(sid, 60)) return result diff --git a/apps/SeleniumService/helpers_uniteddh_preauth.py b/apps/SeleniumService/helpers_uniteddh_preauth.py index d987c22e..3ebd2b0c 100644 --- a/apps/SeleniumService/helpers_uniteddh_preauth.py +++ b/apps/SeleniumService/helpers_uniteddh_preauth.py @@ -294,13 +294,14 @@ async def start_uniteddh_preauth_run(sid: str, data: dict, url: str): s["result"] = result s["message"] = "completed" - # Close browser window (session preserved in profile via UnitedSCO browser manager) + # Keep Chrome open (blank + minimized) so the next job reuses it — faster than + # starting a new browser. The browser manager recreates it if it was closed. try: - from unitedsco_browser_manager import get_browser_manager as _gbm - _gbm().quit_driver() - print("[UnitedDH PreAuth] Browser closed - session preserved in profile") - except Exception as close_err: - print(f"[UnitedDH PreAuth] Could not close browser (non-fatal): {close_err}") + bot.driver.get("about:blank") + bot.driver.minimize_window() + print("[UnitedDH PreAuth] Browser kept open (minimized)") + except Exception as hide_err: + print(f"[UnitedDH PreAuth] Could not minimize browser (non-fatal): {hide_err}") asyncio.create_task(_remove_session_later(sid, 60)) return result diff --git a/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py b/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py index 309cf1dd..0c4a67d0 100644 --- a/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py +++ b/apps/SeleniumService/selenium_CCA_eligibilityCheckWorker.py @@ -45,11 +45,14 @@ class AutomationCCAEligibilityCheck: browser_manager.save_cookies() except Exception as e: print(f"[CCA] Failed to save cookies before close: {e}") + # Keep Chrome open (blank + minimized) so the next CCA job reuses it — faster + # than starting a new browser. get_driver() recreates it if it was closed. try: - browser_manager.quit_driver() - print("[CCA] Browser closed") + self.driver.get("about:blank") + self.driver.minimize_window() + print("[CCA] Browser kept open (minimized)") except Exception as e: - print(f"[CCA] Could not close browser: {e}") + print(f"[CCA] Could not minimize browser: {e}") def _force_logout(self): try: diff --git a/apps/SeleniumService/selenium_CCA_preAuthWorker.py b/apps/SeleniumService/selenium_CCA_preAuthWorker.py index 758a717e..32d29333 100644 --- a/apps/SeleniumService/selenium_CCA_preAuthWorker.py +++ b/apps/SeleniumService/selenium_CCA_preAuthWorker.py @@ -9,7 +9,7 @@ import re import json import base64 import tempfile -from datetime import date +from datetime import date, timedelta from cca_browser_manager import get_browser_manager @@ -34,10 +34,10 @@ class AutomationCCAPreAuth: self.cca_password = claim.get("cca_password", "") or raw.get("cca_password", "") self.memberId = claim.get("memberId", "") self.dateOfBirth = claim.get("dateOfBirth", "") - # PreAuth uses tentative service date; falls back to serviceDate for compatibility + # PreAuth tentative service date: 3 days after today unless one is given explicitly self.serviceDate = ( claim.get("tentativeServiceDate") - or claim.get("serviceDate", "") + or (date.today() + timedelta(days=3)).isoformat() ) self.firstName = claim.get("firstName", "") or first_name self.lastName = claim.get("lastName", "") or last_name @@ -677,17 +677,35 @@ class AutomationCCAPreAuth: print(f"[CCA PreAuth step3] Line {idx+1}: code={code}, tooth={tooth}, billed={billed_str}") # Find the row element scoped to Services grid + # The grid shows 10 rows; rows 11+ sit below its inner scrollbar. + # Scroll the grid's content pane down and retry if the row isn't found yet. row_el = None - for xpath in [ - f"//div[@id='Services']//tr[@aria-rowindex='{row_idx}']", - f"//div[@id='Services']//div[contains(@class,'e-gridcontent')]//tbody/tr[{idx+1}]", - f"//div[@id='Services']//div[contains(@class,'e-content')]//tr[{idx+1}]", - ]: - try: - row_el = self.driver.find_element(By.XPATH, xpath) + for _scroll_try in range(4): + for xpath in [ + f"//div[@id='Services']//tr[@aria-rowindex='{row_idx}']", + f"//div[@id='Services']//div[contains(@class,'e-gridcontent')]//tbody/tr[{idx+1}]", + f"//div[@id='Services']//div[contains(@class,'e-content')]//tr[{idx+1}]", + ]: + try: + row_el = self.driver.find_element(By.XPATH, xpath) + break + except Exception: + continue + if row_el: break + try: + self.driver.execute_script( + "var c=document.querySelector('#Services .e-content');" + "if(c){c.scrollTop=c.scrollTop+c.clientHeight;}") + time.sleep(0.6) except Exception: - continue + pass + if row_el: + try: + self.driver.execute_script( + "arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});", row_el) + except Exception: + pass if not row_el: print(f"[CCA PreAuth step3] WARNING: row {idx+1} not found — skipping") diff --git a/apps/SeleniumService/selenium_TuftsSCO_preAuthWorker.py b/apps/SeleniumService/selenium_TuftsSCO_preAuthWorker.py index b1e0ecbc..ac244ed0 100644 --- a/apps/SeleniumService/selenium_TuftsSCO_preAuthWorker.py +++ b/apps/SeleniumService/selenium_TuftsSCO_preAuthWorker.py @@ -19,6 +19,7 @@ import base64 import re from dentaquest_browser_manager import get_browser_manager +from datetime import date, timedelta MEMBERS_URL = "https://providers.dentaquest.com/members" @@ -37,7 +38,11 @@ class AutomationTuftsSCOPreAuth: self.dateOfBirth = claim.get("dateOfBirth", "") self.firstName = claim.get("firstName", "") self.lastName = claim.get("lastName", "") - self.serviceDate = claim.get("serviceDate", "") + # PreAuth tentative service date: 3 days after today unless one is given explicitly + self.serviceDate = ( + claim.get("tentativeServiceDate") + or (date.today() + timedelta(days=3)).isoformat() + ) self.serviceLines = claim.get("serviceLines", []) self.claimFiles = claim.get("claimFiles", []) self.patientName = claim.get("patientName", "")