fix: AI claims/preauths keep all codes past 10; reuse CCA/DentalHub Chrome
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface IStorage {
|
||||
getPatientByInsuranceId(insuranceId: string): Promise<Patient | null>;
|
||||
getPatientByInsuranceIdAndDob(insuranceId: string, dob: Date): Promise<Patient | null>;
|
||||
getPatientByInsuranceIdNoDob(insuranceId: string): Promise<Patient | null>;
|
||||
getPatientByNameAndDob(firstName: string, lastName: string, dob: Date): Promise<Patient | null>;
|
||||
getAllPatients(): Promise<Patient[]>;
|
||||
getRecentPatients(limit: number, offset: number): Promise<Patient[]>;
|
||||
getPatientsByIds(ids: number[]): Promise<Patient[]>;
|
||||
@@ -81,6 +82,21 @@ export const patientsStorage: IStorage = {
|
||||
});
|
||||
},
|
||||
|
||||
async getPatientByNameAndDob(firstName: string, lastName: string, dob: Date): Promise<Patient | null> {
|
||||
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<Patient[]> {
|
||||
return db.patient.findMany({
|
||||
skip: offset,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user