feat: AI-powered eligibility detection from Copy Agent screenshots

Adds an "AI Detect & Check Eligibility" action to the Copy Agent page that
sends captured screenshots to Claude to extract Member ID/DOB, then opens
the chatbot's eligibility confirm step pre-filled with the result.

- POST /api/ai/detect-eligibility-info: Claude vision extraction of
  Member ID/DOB from uploaded screenshots.
- POST /api/patients/create-temp: auto-creates a placeholder "PID-<id>"
  patient (matched by insuranceId+dob) when no patient is selected, so
  screenshots can be saved immediately; the real eligibility check later
  rewrites the patient's name and Cloud Storage folder automatically via
  the existing createOrUpdatePatientByInsuranceId matching logic.
- Chatbot: Member ID/DOB on the confirm card are now editable (MM/DD/YYYY),
  with edits synced back to the patient record before the check runs.
- Fixed a useEffect ordering bug on the insurance-status page that could
  silently clear a chatbot-prefilled Member ID/DOB before the auto-trigger
  effect ran, preventing the eligibility check from ever starting.
- Added a local-disk-only screenshot backup (apps/Backend/uploads/
  screenshot-backups/) for debugging, not exposed anywhere in the UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 21:20:33 -04:00
co-authored by Claude Sonnet 5
parent 34312b62bc
commit c95d08c951
9 changed files with 748 additions and 57 deletions
@@ -94,6 +94,8 @@ function waitForSeleniumJob(
});
}
const PENDING_COPY_AGENT_SCREENSHOTS_KEY = "copy_agent_pending_screenshots";
export default function InsuranceStatusPage() {
const { user } = useAuth();
const { toast } = useToast();
@@ -181,18 +183,32 @@ export default function InsuranceStatusPage() {
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
if (id) setMemberId(id);
if (dob) {
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD
const normalized = /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dob)
? (() => { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })()
: dob;
setDateOfBirth(parseLocalDate(normalized));
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD.
// A malformed date (e.g. hand-edited in the chatbot) must not abort the rest of
// this function — otherwise pendingAutoCheck/prefillTick below never get set and
// the auto-trigger effect silently never fires (no error, Selenium never launches).
try {
const normalized = /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dob)
? (() => { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })()
: dob;
setDateOfBirth(parseLocalDate(normalized));
} catch (dobErr) {
console.error("[insurance-status] failed to parse prefilled DOB:", dob, dobErr);
toast({
title: "Invalid Date of Birth",
description: `Couldn't parse "${dob}" — please re-enter the Date of Birth below and try again.`,
variant: "destructive",
});
}
}
if (ac) pendingAutoCheck.current = ac;
sessionStorage.removeItem("chatbot_eligibility");
// Increment tick so the auto-trigger effect re-fires even when
// memberId/dob are unchanged (same patient submitted a second time).
setPrefillTick((n) => n + 1);
} catch {}
} catch (err) {
console.error("[insurance-status] failed to apply chatbot eligibility prefill:", err);
}
};
apply();
window.addEventListener("chatbot:eligibility-prefill", apply);
@@ -201,6 +217,7 @@ export default function InsuranceStatusPage() {
}, []);
// Populate fields from selected patient
const didMountSelectedPatientEffect = useRef(false);
useEffect(() => {
if (selectedPatient) {
setMemberId(selectedPatient.insuranceId ?? "");
@@ -212,12 +229,16 @@ export default function InsuranceStatusPage() {
? parseLocalDate(selectedPatient.dateOfBirth)
: selectedPatient.dateOfBirth;
setDateOfBirth(dob ?? null);
} else {
} else if (didMountSelectedPatientEffect.current) {
// Only clear on an actual deselection (user action) — not on the initial mount, where
// selectedPatient starts null by default and would otherwise wipe out values the
// chatbot-prefill effect (above) just set from sessionStorage on this same page load.
setMemberId("");
setFirstName("");
setLastName("");
setDateOfBirth(null);
}
didMountSelectedPatientEffect.current = true;
}, [selectedPatient]);
// Auto-lookup patient by member ID when typed manually
@@ -286,6 +307,49 @@ export default function InsuranceStatusPage() {
return new Date() < cutoff;
};
// If the Copy Agent page couldn't read a Member ID/DOB from a screenshot, it stashes the
// screenshot(s) here and lets the user complete the check manually. Once that check resolves
// to a real patient, attach the originally-captured screenshots to that patient's Attachments.
const flushPendingCopyAgentScreenshots = async (jobResult: any) => {
let raw: string | null = null;
try {
raw = sessionStorage.getItem(PENDING_COPY_AGENT_SCREENSHOTS_KEY);
} catch {}
if (!raw || !jobResult?.patientId) return;
try {
sessionStorage.removeItem(PENDING_COPY_AGENT_SCREENSHOTS_KEY);
const items: { dataUrl: string; fileName: string }[] = JSON.parse(raw);
if (!items.length) return;
let patientName = `Patient ${jobResult.patientId}`;
try {
const res = await apiRequest("GET", `/api/patients/${jobResult.patientId}`);
const patient: Patient = await res.json();
const full = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
if (full) patientName = full;
} catch {}
const formData = new FormData();
formData.append("patientId", String(jobResult.patientId));
formData.append("patientName", patientName);
formData.append("screenshotBackup", "true");
for (const item of items) {
const blobRes = await fetch(item.dataUrl);
const blob = await blobRes.blob();
formData.append("files", new File([blob], `${item.fileName}.png`, { type: "image/png" }));
}
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
toast({
title: "Screenshots attached",
description: `Screenshots from Copy Agent were attached to ${patientName}'s attachments.`,
});
} catch (err) {
console.error("Failed to attach pending Copy Agent screenshots:", err);
}
};
// Shared: run MH eligibility selenium job, return jobResult or throw
const runMHEligibilitySelenium = async (): Promise<any> => {
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
@@ -325,9 +389,11 @@ export default function InsuranceStatusPage() {
}),
);
return waitForSeleniumJob(jobId, (msg) =>
const jobResult = await waitForSeleniumJob(jobId, (msg) =>
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
);
await flushPendingCopyAgentScreenshots(jobResult);
return jobResult;
};
const handleAddPatient = async () => {