feat: chatbot screenshot-only eligibility detect, Sun Life/DentaQuest auto-check, chatbot attachment handoff to preauth; fix TuftsSCO preauth Selenium reliability

- Chatbot: submitting with only a screenshot attached (no text) now triggers the same
  "AI Detect & Check Eligibility" flow as the Copy Agent page
- detect-eligibility-info now also extracts the visible insurance payer name and picks
  Tufts SCO auto-check when both Sun Life and DentaQuest are detected, else MassHealth
- Chatbot-attached files are now handed off to both claim and preauth forms unconditionally,
  not just claims
- TuftsSCO preauth Selenium worker: verify-and-retry the Tooth field (typing was silently
  getting reset by duplicate-procedure-code warning banners), add a final re-verification
  pass across all rows, fix acknowledgement-checkbox targeting/verification, and verify
  the "Next step" click actually advances the wizard instead of trusting a blind click
This commit is contained in:
2026-07-25 00:10:05 -04:00
parent 6b9b3c41f2
commit 870bda5950
8 changed files with 237 additions and 724 deletions

View File

@@ -378,7 +378,8 @@ router.post(
type: "text",
text:
"Extract the Member ID and Date of Birth from this dental/insurance eligibility screenshot. " +
'Respond with strict JSON only, no prose, no markdown fences: {"memberId": string|null, "dob": "YYYY-MM-DD"|null}',
"Also read any visible insurance payer/plan name text (e.g. \"Sun Life\", \"DentaQuest\", \"MassHealth\") exactly as shown. " +
'Respond with strict JSON only, no prose, no markdown fences: {"memberId": string|null, "dob": "YYYY-MM-DD"|null, "insuranceProvider": string|null}',
},
...files.map((f) => ({
type: "image_url",
@@ -390,10 +391,11 @@ router.post(
let memberId: string | null = null;
let dob: string | null = null;
let insuranceProvider: string | null = null;
try {
const raw = String(response.content).trim();
const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
const parsed = JSON.parse(jsonStr) as { memberId?: string | null; dob?: string | null };
const parsed = JSON.parse(jsonStr) as { memberId?: string | null; dob?: string | null; insuranceProvider?: string | null };
// Sanitize once here so this is the single source of truth for the memberId
// used downstream (temp-patient creation, chatbot prefill, real eligibility check) —
// any mismatch in formatting would cause createOrUpdatePatientByInsuranceId to create
@@ -401,11 +403,19 @@ router.post(
const cleanedMemberId = (parsed.memberId || "").replace(/[^A-Za-z0-9]/g, "");
memberId = cleanedMemberId || null;
dob = parsed.dob || null;
insuranceProvider = parsed.insuranceProvider || null;
} catch (parseErr) {
console.error("[detect-eligibility-info] failed to parse AI response", parseErr);
}
return res.status(200).json({ error: false, data: { memberId, dob } });
// Sun Life now brands the DentaQuest/Tufts SCO portal — screenshots showing both
// names together mean the Tufts SCO auto-check, not the MassHealth default.
const providerText = (insuranceProvider || "").toLowerCase();
const hasSunLife = /sun\s*life/.test(providerText);
const hasDentaQuest = /denta\s*quest/.test(providerText);
const autoCheck = hasSunLife && hasDentaQuest ? "tufts-sco" : "mh";
return res.status(200).json({ error: false, data: { memberId, dob, insuranceProvider, autoCheck } });
} catch (err) {
console.error("[detect-eligibility-info]", err);
return res.status(500).json({ error: true, message: "Failed to detect eligibility info", details: String(err) });

View File

@@ -535,16 +535,18 @@ export function ClaimForm({
};
}, [appointmentId, serviceDate, existingClaimId]);
// Attach any file the user uploaded/screenshotted in the chatbot to this claim/preauth,
// regardless of whether the user typed anything like "with the attachment" — the mere
// act of attaching it in chat is enough intent.
useEffect(() => {
const chatbotFiles = takeChatbotPendingFiles();
if (chatbotFiles.length === 0) return;
setForm((prev) => ({ ...prev, uploadedFiles: chatbotFiles }));
}, []);
// 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 {

View File

@@ -398,7 +398,7 @@ export function ChatbotButton() {
});
addMsg(
"bot",
`I read this from your screenshots — ready to check ${detail.autoCheck === "mh" ? "MassHealth" : "eligibility"} for ${
`I read this from your screenshots — ready to check ${detail.autoCheck === "tufts-sco" ? "Tufts SCO" : detail.autoCheck === "mh" ? "MassHealth" : "eligibility"} for ${
detail.patient ? `${detail.patient.firstName} ${detail.patient.lastName}` : detail.memberId
}. Confirm to proceed.`
);
@@ -663,9 +663,56 @@ export function ChatbotButton() {
prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck);
};
// Screenshot(s) attached with no typed text: same intent as the Copy Agent's
// "AI Detect & Check Eligibility" button — read Member ID/DOB off the image(s).
const handleScreenshotOnlyDetect = async () => {
const files = pendingFiles;
if (!files.length || step === "ai-loading") return;
setPendingFiles([]);
addMsg("user", `📎 ${files.length} screenshot${files.length > 1 ? "s" : ""}`);
addMsg("bot", "Reading your screenshot...", true);
setStep("ai-loading");
try {
const formData = new FormData();
for (const file of files) formData.append("files", file);
const res = await apiRequest("POST", "/api/ai/detect-eligibility-info", formData);
const json = await res.json();
if (json?.error) throw new Error(json.message || "Detection failed");
const memberId: string | null = json?.data?.memberId ?? null;
const dob: string | null = json?.data?.dob ?? null;
const autoCheck: string = json?.data?.autoCheck ?? "mh";
if (memberId && dob) {
setEligibilityIdData({
memberId,
dob,
siteKey: "",
autoCheck,
patient: null,
appointmentDate: null,
});
replaceLastMsg(`I read this from your screenshot — ready to check ${autoCheck === "tufts-sco" ? "Tufts SCO" : "MassHealth"} for ${memberId}. Confirm to proceed.`);
setStep("eligibility-id-ready");
} else {
setPasteInput([memberId, dob].filter(Boolean).join(" "));
replaceLastMsg("I saved your screenshot but couldn't fully read the Member ID/DOB. Please review and complete the fields below:");
setStep("eligibility-input");
}
} catch {
replaceLastMsg("Sorry, I couldn't read that screenshot. Please try again.");
setStep("menu");
}
};
const handleFreeTextSubmit = async () => {
const text = freeTextInput.trim();
if (!text || step === "ai-loading") return;
if (step === "ai-loading") return;
if (!text) {
if (pendingFiles.length > 0) return handleScreenshotOnlyDetect();
return;
}
setFreeTextInput("");
addMsg("user", text);
addMsg("bot", "Thinking...", true);
@@ -1785,7 +1832,7 @@ export function ChatbotButton() {
size="sm"
className="h-9 w-9 p-0 shrink-0"
onClick={handleFreeTextSubmit}
disabled={!freeTextInput.trim() || step === "ai-loading"}
disabled={(!freeTextInput.trim() && pendingFiles.length === 0) || step === "ai-loading"}
>
{step === "ai-loading" ? (
<Loader2 className="h-4 w-4 animate-spin" />

View File

@@ -301,13 +301,14 @@ export default function AiCopyAgentPage() {
const memberId: string | null = detectResult.value?.data?.memberId ?? null;
const dob: string | null = detectResult.value?.data?.dob ?? null;
const autoCheck: string = detectResult.value?.data?.autoCheck ?? "mh";
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: {
memberId,
dob,
autoCheck: "mh",
autoCheck,
patient: {
id: patient.id,
firstName: patient.firstName,
@@ -336,11 +337,11 @@ export default function AiCopyAgentPage() {
try {
const files = await itemsToFiles(items);
let detected: { memberId: string | null; dob: string | null };
let detected: { memberId: string | null; dob: string | null; autoCheck: string };
try {
const json = await detectFromFiles(files);
if (json?.error) throw new Error(json.message || "Detection failed");
detected = { memberId: json?.data?.memberId ?? null, dob: json?.data?.dob ?? null };
detected = { memberId: json?.data?.memberId ?? null, dob: json?.data?.dob ?? null, autoCheck: json?.data?.autoCheck ?? "mh" };
} catch (error) {
console.error("Error detecting Member ID/DOB:", error);
toast({
@@ -351,7 +352,7 @@ export default function AiCopyAgentPage() {
return;
}
const { memberId, dob } = detected;
const { memberId, dob, autoCheck } = detected;
if (memberId && dob) {
let tempPatient: TempPatient;
@@ -395,7 +396,7 @@ export default function AiCopyAgentPage() {
detail: {
memberId,
dob,
autoCheck: "mh",
autoCheck,
patient: {
id: tempPatient.id,
firstName: tempPatient.firstName,
@@ -424,7 +425,7 @@ export default function AiCopyAgentPage() {
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: { memberId, dob, autoCheck: "mh", patient: null },
detail: { memberId, dob, autoCheck, patient: null },
})
);
}

View File

@@ -596,6 +596,36 @@ class AutomationTuftsSCOPreAuth:
except Exception as e:
print(f"[TuftsSCO PreAuth step4] Warning: could not fill {label}: {e}")
def _fill_tooth_field(self, idx, tooth, context=""):
"""Fill the Tooth input for row idx, verifying the DOM value against what was typed
and retrying if it didn't stick. Entering the same procedure code on multiple rows
(e.g. one code across several teeth) triggers a duplicate-code warning banner that can
re-render the form and silently reset earlier rows' Tooth values — callers should re-run
this for every row after the whole form is filled, not just once at type-time."""
tooth = str(tooth).strip()
actual = None
for attempt in range(3):
tooth_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Tooth']")
if idx >= len(tooth_inputs):
return None
tooth_inp = tooth_inputs[idx]
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", tooth_inp)
self.driver.execute_script("arguments[0].focus();", tooth_inp)
time.sleep(0.2)
tooth_inp.send_keys(Keys.CONTROL + "a")
tooth_inp.send_keys(Keys.DELETE)
tooth_inp.send_keys(tooth)
time.sleep(0.3)
actual = (tooth_inp.get_attribute("value") or "").strip()
if actual == tooth:
print(f"[TuftsSCO PreAuth step4] tooth[{idx}]{context}: confirmed '{tooth}'")
return actual
print(f"[TuftsSCO PreAuth step4] tooth[{idx}]{context}: attempt {attempt + 1}"
f"expected '{tooth}', field shows {actual!r}, retrying")
print(f"[TuftsSCO PreAuth step4] WARNING: tooth[{idx}]{context} did not stick — "
f"expected '{tooth}', field shows {actual!r} after 3 attempts")
return actual
def _fill_text_input(self, inp, value, label="field"):
try:
inp.click()
@@ -655,17 +685,8 @@ class AutomationTuftsSCOPreAuth:
if tooth:
try:
tooth_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Tooth']")
if idx < len(tooth_inputs):
tooth_inp = tooth_inputs[idx]
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", tooth_inp)
self.driver.execute_script("arguments[0].focus();", tooth_inp)
time.sleep(0.2)
tooth_inp.send_keys(Keys.CONTROL + "a")
tooth_inp.send_keys(Keys.DELETE)
tooth_inp.send_keys(str(tooth))
print(f"[TuftsSCO PreAuth step4] tooth[{idx}]: typed '{tooth}'")
time.sleep(0.3)
self._fill_tooth_field(idx, tooth)
time.sleep(0.1)
except Exception as e:
print(f"[TuftsSCO PreAuth step4] Could not fill tooth: {e}")
@@ -714,6 +735,24 @@ class AutomationTuftsSCOPreAuth:
except Exception as e:
print(f"[TuftsSCO PreAuth step4] Could not fill billed amount: {e}")
# Adding a row with a procedure code already used by an earlier row triggers a
# duplicate-code warning banner that re-renders the form — this can silently reset
# a previously-confirmed Tooth value on an earlier row. Re-check every row now that
# no more rows will be added (no more duplicate-code banners can fire after this).
print("[TuftsSCO PreAuth step4] Final tooth verification pass...")
for idx, line in enumerate(active_lines):
tooth = str(line.get("toothNumber") or line.get("tooth") or "").strip()
if not tooth:
continue
tooth_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Tooth']")
if idx >= len(tooth_inputs):
continue
actual = (tooth_inputs[idx].get_attribute("value") or "").strip()
if actual == tooth:
continue
print(f"[TuftsSCO PreAuth step4] Final check: tooth[{idx}] regressed to {actual!r}, re-filling '{tooth}'")
self._fill_tooth_field(idx, tooth, context=" (final pass)")
print("[TuftsSCO PreAuth step4] Done")
return "SUCCESS"
@@ -777,27 +816,56 @@ class AutomationTuftsSCOPreAuth:
# ── Step 6: Click "Next step" ──────────────────────────────────────────────
def step6_click_next(self):
"""Click the 'Next step' button."""
"""Click the 'Next step' button and verify the wizard actually advanced to the
acknowledgement page — the URL doesn't change (SPA), so a click that's silently
ignored (e.g. button still disabled from a validation error) would otherwise look
identical to a successful one in the logs."""
try:
print(f"[TuftsSCO PreAuth step6] Current URL: {self.driver.current_url}")
btn = WebDriverWait(self.driver, 15).until(
EC.element_to_be_clickable((By.XPATH,
"//button[@data-testid='next-step-btn'] | "
"//button[@aria-label='Next step']"
))
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn)
time.sleep(0.5)
self.driver.execute_script("""
var el = arguments[0];
el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true}));
""", btn)
print("[TuftsSCO PreAuth step6] Clicked 'Next step'")
time.sleep(2)
advanced = False
for attempt in range(3):
btn = WebDriverWait(self.driver, 15).until(
EC.element_to_be_clickable((By.XPATH,
"//button[@data-testid='next-step-btn'] | "
"//button[@aria-label='Next step']"
))
)
disabled = (btn.get_attribute("disabled") is not None) or \
((btn.get_attribute("aria-disabled") or "").lower() == "true")
if disabled:
print(f"[TuftsSCO PreAuth step6] 'Next step' is disabled (attempt {attempt + 1}) — "
f"a form field may still be invalid/empty; waiting before retry")
time.sleep(1.5)
continue
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn)
time.sleep(0.5)
self.driver.execute_script("""
var el = arguments[0];
el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true}));
""", btn)
print(f"[TuftsSCO PreAuth step6] Clicked 'Next step' (attempt {attempt + 1})")
try:
WebDriverWait(self.driver, 6).until(
EC.presence_of_element_located((By.XPATH,
"//label[contains(.,'submitting this')] | "
"//*[contains(@aria-label,'submitting this')]"
))
)
advanced = True
print("[TuftsSCO PreAuth step6] Confirmed wizard advanced to the acknowledgement step")
break
except TimeoutException:
print(f"[TuftsSCO PreAuth step6] Page did not advance after click (attempt {attempt + 1}), retrying")
print(f"[TuftsSCO PreAuth step6] URL after Next: {self.driver.current_url}")
if not advanced:
return "ERROR: step6 failed: 'Next step' click did not advance the wizard after 3 attempts"
return "SUCCESS"
except Exception as e:
print(f"[TuftsSCO PreAuth step6] Exception: {e}")
@@ -805,30 +873,65 @@ class AutomationTuftsSCOPreAuth:
# ── Step 7: Acknowledge + submit ────────────────────────────────────────────
@staticmethod
def _is_checkbox_checked(el):
try:
if el.is_selected():
return True
except Exception:
pass
state = (el.get_attribute("aria-checked") or el.get_attribute("checked") or "").lower()
return state in ("true", "checked")
def step7_submit_preauth(self):
"""On the pre-auth summary page, tick the acknowledgement checkbox then submit."""
try:
print(f"[TuftsSCO PreAuth step7] Current URL: {self.driver.current_url}")
checkbox = WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.XPATH,
"//input[@type='checkbox'] | "
"//*[@role='checkbox'] | "
"//label[contains(.,'submitting this')]//input | "
"//*[contains(@aria-label,'submitting this')]"
))
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", checkbox)
time.sleep(0.3)
self.driver.execute_script("""
var el = arguments[0];
el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true}));
""", checkbox)
print("[TuftsSCO PreAuth step7] Checked acknowledgement checkbox")
time.sleep(0.5)
# A bare "//input[@type='checkbox']" would match the FIRST checkbox in the DOM,
# which may not be the acknowledgement box (e.g. a row-selection checkbox in the
# service-line table). Try specific, label-anchored selectors before falling back.
checkbox = None
for xpath in [
"//label[contains(.,'submitting this')]//input[@type='checkbox']",
"//*[contains(@aria-label,'submitting this')]",
"//label[contains(translate(., 'ACKNOWLEDGE', 'acknowledge'),'acknowledge')]//input[@type='checkbox']",
"//*[contains(translate(@aria-label, 'ACKNOWLEDGE', 'acknowledge'),'acknowledge')]",
"//input[@type='checkbox']",
"//*[@role='checkbox']",
]:
try:
checkbox = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, xpath))
)
print(f"[TuftsSCO PreAuth step7] Found acknowledgement checkbox via: {xpath}")
break
except Exception:
continue
if checkbox is None:
return "ERROR: step7 failed: could not find acknowledgement checkbox"
for attempt in range(3):
if self._is_checkbox_checked(checkbox):
break
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", checkbox)
time.sleep(0.3)
self.driver.execute_script("""
var el = arguments[0];
el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true}));
""", checkbox)
time.sleep(0.5)
if attempt > 0:
print(f"[TuftsSCO PreAuth step7] Acknowledgement checkbox retry {attempt + 1}")
if self._is_checkbox_checked(checkbox):
print("[TuftsSCO PreAuth step7] Confirmed acknowledgement checkbox is checked")
else:
print("[TuftsSCO PreAuth step7] WARNING: acknowledgement checkbox did not register as checked after 3 attempts")
all_btns = self.driver.find_elements(By.XPATH, "//button")
print(f"[TuftsSCO PreAuth step7] Buttons: {[b.get_attribute('aria-label') or b.text[:40] for b in all_btns]}")

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Appointment" ADD COLUMN "npiProviderId" INTEGER;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_npiProviderId_fkey" FOREIGN KEY ("npiProviderId") REFERENCES "NpiProvider"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,8 +1,8 @@
{
"version": "1.0",
"generatorVersion": "1.0.0",
"generatedAt": "2026-07-21T01:43:44.482Z",
"outputPath": "/home/ff/Desktop/DentalManagementMH07/packages/db/shared",
"generatedAt": "2026-07-21T03:13:42.342Z",
"outputPath": "/home/gg/Desktop/DentalManagementMH07/packages/db/shared",
"files": [
"schemas/enums/TransactionIsolationLevel.schema.ts",
"schemas/enums/UserScalarFieldEnum.schema.ts",

File diff suppressed because it is too large Load Diff