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
+358 -43
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { Camera, Copy, RotateCcw, Save } from "lucide-react";
import { Camera, Copy, Plus, RotateCcw, Save, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -33,6 +33,20 @@ function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
return canvas.toDataURL("image/png");
}
interface BatchedScreenshot {
id: string;
dataUrl: string;
fileName: string;
}
interface TempPatient {
id: number;
firstName: string | null;
lastName: string | null;
}
const PENDING_SCREENSHOTS_KEY = "copy_agent_pending_screenshots";
export default function AiCopyAgentPage() {
const { toast } = useToast();
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
@@ -43,6 +57,8 @@ export default function AiCopyAgentPage() {
const [isCapturing, setIsCapturing] = useState(false);
const [countdown, setCountdown] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [batch, setBatch] = useState<BatchedScreenshot[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -132,47 +148,97 @@ export default function AiCopyAgentPage() {
handleCapture();
};
const clearActive = () => {
setScreenshot(null);
setFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
};
// Applies the current crop to the active screenshot and returns it, without touching state.
const finalizeActive = (): BatchedScreenshot | null => {
if (!screenshot) return null;
const croppedImage =
completedCrop && imgRef.current
? getCroppedDataUrl(imgRef.current, completedCrop)
: screenshot;
const safeName =
fileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
return { id: crypto.randomUUID(), dataUrl: croppedImage, fileName: safeName };
};
const handleAddAnother = async () => {
const item = finalizeActive();
if (!item) return;
setBatch((prev) => [...prev, item]);
clearActive();
await handleCapture();
};
const handleRemoveBatchItem = (id: string) => {
setBatch((prev) => prev.filter((item) => item.id !== id));
};
const getPatientName = (patient: Patient) =>
patient.firstName && patient.lastName
? `${patient.firstName} ${patient.lastName}`
: patient.firstName ?? `patient-${patient.id}`;
const itemsToFiles = async (items: BatchedScreenshot[]): Promise<File[]> => {
const files: File[] = [];
for (const item of items) {
const response = await fetch(item.dataUrl);
const blob = await response.blob();
files.push(new File([blob], `${item.fileName}.png`, { type: "image/png" }));
}
return files;
};
const uploadToPatient = async (
patientId: number | undefined,
patientName: string,
files: File[],
pending: boolean
) => {
const formData = new FormData();
formData.append("patientId", String(patientId));
formData.append("patientName", patientName);
if (pending) formData.append("pending", "true");
// Lets the backend keep a local-disk-only debug copy — not shown anywhere in the UI.
formData.append("screenshotBackup", "true");
for (const file of files) formData.append("files", file);
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
};
const getPendingItems = (): BatchedScreenshot[] => {
const items = [...batch];
const active = finalizeActive();
if (active) items.push(active);
return items;
};
const handleSave = async () => {
if (!screenshot || !selectedPatient) return;
if (!selectedPatient) return;
const items = getPendingItems();
if (!items.length) return;
setIsSaving(true);
try {
const croppedImage =
completedCrop && imgRef.current
? getCroppedDataUrl(imgRef.current, completedCrop)
: screenshot;
const response = await fetch(croppedImage);
const blob = await response.blob();
const safeName =
fileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
const file = new File([blob], `${safeName}.png`, { type: "image/png" });
const patientName =
selectedPatient.firstName && selectedPatient.lastName
? `${selectedPatient.firstName} ${selectedPatient.lastName}`
: selectedPatient.firstName ?? `patient-${selectedPatient.id}`;
const formData = new FormData();
formData.append("patientId", String(selectedPatient.id));
formData.append("patientName", patientName);
formData.append("files", file);
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
const patientName = getPatientName(selectedPatient);
const files = await itemsToFiles(items);
await uploadToPatient(selectedPatient.id, patientName, files, false);
toast({
title: "Saved",
description: `Screenshot saved to ${patientName}'s attachments.`,
description: `${items.length} screenshot${items.length > 1 ? "s" : ""} saved to ${patientName}'s attachments.`,
});
setScreenshot(null);
setFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
setBatch([]);
clearActive();
} catch (error) {
console.error("Error saving screenshot:", error);
toast({
title: "Error",
description: "Failed to save screenshot to patient attachments.",
description: "Failed to save screenshots to patient attachments.",
variant: "destructive",
});
} finally {
@@ -180,6 +246,193 @@ export default function AiCopyAgentPage() {
}
};
const detectFromFiles = async (files: File[]) => {
const detectFormData = new FormData();
for (const file of files) detectFormData.append("files", file);
const response = await apiRequest("POST", "/api/ai/detect-eligibility-info", detectFormData);
return response.json();
};
const handleAiDetectAndCheck = async () => {
const items = getPendingItems();
if (!items.length) return;
if (selectedPatient) {
await runAiDetectForSelectedPatient(selectedPatient, items);
} else {
await runAiDetectWithoutPatient(items);
}
};
const runAiDetectForSelectedPatient = async (patient: Patient, items: BatchedScreenshot[]) => {
setIsDetecting(true);
try {
const patientName = getPatientName(patient);
const files = await itemsToFiles(items);
const [uploadResult, detectResult] = await Promise.allSettled([
uploadToPatient(patient.id, patientName, files, true),
detectFromFiles(files),
]);
const saved = uploadResult.status === "fulfilled";
if (saved) {
setBatch([]);
clearActive();
}
if (detectResult.status === "rejected" || detectResult.value?.error) {
toast({
title: saved ? "Saved, but AI detection failed" : "Error",
description: saved
? "Screenshots saved. Enter the Member ID/DOB manually in the chatbot to check eligibility."
: "Failed to save screenshots and detect Member ID/DOB.",
variant: saved ? undefined : "destructive",
});
return;
}
if (!saved) {
toast({
title: "Detected, but save failed",
description: "Read the Member ID/DOB, but failed to save the screenshots. Try Save All again.",
variant: "destructive",
});
}
const memberId: string | null = detectResult.value?.data?.memberId ?? null;
const dob: string | null = detectResult.value?.data?.dob ?? null;
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: {
memberId,
dob,
autoCheck: "mh",
patient: {
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
insuranceId: patient.insuranceId ?? null,
insuranceProvider: null,
dateOfBirth: patient.dateOfBirth ?? null,
},
patientId: patient.id,
pendingFolderPatientName: patientName,
},
})
);
} finally {
setIsDetecting(false);
}
};
// No patient selected: detect first (need memberId+dob to create a patient at all), then
// either auto-create a temp "PID-<id>" patient and save under it, or — if detection came back
// incomplete — leave the screenshots unsaved and let the user complete the details in the
// chatbot manually; insurance-status-page.tsx picks the pending screenshots back up once that
// manual check resolves to a real patient.
const runAiDetectWithoutPatient = async (items: BatchedScreenshot[]) => {
setIsDetecting(true);
try {
const files = await itemsToFiles(items);
let detected: { memberId: string | null; dob: string | null };
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 };
} catch (error) {
console.error("Error detecting Member ID/DOB:", error);
toast({
title: "Error",
description: "Failed to read the screenshots. Please try again.",
variant: "destructive",
});
return;
}
const { memberId, dob } = detected;
if (memberId && dob) {
let tempPatient: TempPatient;
try {
const res = await apiRequest("POST", "/api/patients/create-temp", { memberId, dob });
const json = await res.json();
tempPatient = json.data;
} catch (error) {
console.error("Error creating temp patient:", error);
toast({
title: "Error",
description: "Failed to create a patient record for this screenshot.",
variant: "destructive",
});
return;
}
const patientName = getPatientName(tempPatient as Patient);
try {
await uploadToPatient(tempPatient.id, patientName, files, false);
} catch (error) {
console.error("Error saving screenshots to temp patient:", error);
toast({
title: "Detected, but save failed",
description: `Created ${patientName}, but failed to save the screenshots. Select them from the patient list and try Save All.`,
variant: "destructive",
});
return;
}
setBatch([]);
clearActive();
setSelectedPatient(tempPatient as Patient);
toast({
title: "Patient created",
description: `Saved as ${patientName}. Its name will update automatically once the eligibility check confirms it.`,
});
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: {
memberId,
dob,
autoCheck: "mh",
patient: {
id: tempPatient.id,
firstName: tempPatient.firstName,
lastName: tempPatient.lastName,
insuranceId: memberId,
insuranceProvider: null,
dateOfBirth: dob,
},
patientId: tempPatient.id,
},
})
);
} else {
try {
sessionStorage.setItem(
PENDING_SCREENSHOTS_KEY,
JSON.stringify(items.map((item) => ({ dataUrl: item.dataUrl, fileName: item.fileName })))
);
} catch {}
toast({
title: "Couldn't read Member ID/DOB",
description:
"Complete the details in the chatbot — your screenshots will attach automatically once that check finishes.",
});
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: { memberId, dob, autoCheck: "mh", patient: null },
})
);
}
} finally {
setIsDetecting(false);
}
};
return (
<div className="container mx-auto space-y-6">
@@ -200,10 +453,34 @@ export default function AiCopyAgentPage() {
<CardHeader>
<CardTitle>Capture Screenshot</CardTitle>
<CardDescription>
Capture your screen and save it to a patient's attachments.
Capture your screen and save it to a patient's attachments. Take multiple
screenshots and save them together as one batch.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{batch.length > 0 && (
<div className="flex flex-wrap gap-2">
{batch.map((item) => (
<div key={item.id} className="relative">
<img
src={item.dataUrl}
alt={item.fileName}
className="h-16 w-16 rounded-md border object-cover"
/>
<button
type="button"
onClick={() => handleRemoveBatchItem(item.id)}
disabled={isSaving}
className="absolute -top-1.5 -right-1.5 rounded-full bg-background border shadow-sm p-0.5"
title="Remove from batch"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{screenshot ? (
<div className="space-y-4">
<ReactCrop
@@ -238,30 +515,67 @@ export default function AiCopyAgentPage() {
<RotateCcw className="h-4 w-4 mr-2" />
Retake
</Button>
<Button variant="outline" onClick={handleAddAnother} disabled={isCapturing || isSaving}>
<Plus className="h-4 w-4 mr-2" />
Add Another
</Button>
<Button
onClick={handleSave}
disabled={!selectedPatient || isSaving}
disabled={!selectedPatient || isSaving || isDetecting}
title={!selectedPatient ? "Select a patient below first" : undefined}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : "Save to Patient"}
{isSaving ? "Saving..." : `Save All${batch.length ? ` (${batch.length + 1})` : ""}`}
</Button>
<Button
variant="secondary"
onClick={handleAiDetectAndCheck}
disabled={isSaving || isDetecting}
>
<Sparkles className="h-4 w-4 mr-2" />
{isDetecting ? "Detecting..." : "AI Detect & Check Eligibility"}
</Button>
</div>
{!selectedPatient && (
<p className="text-xs text-muted-foreground">
Select a patient below to enable saving.
Select a patient below to enable Save All, or use "AI Detect &amp; Check
Eligibility" without selecting one to create a patient automatically.
</p>
)}
</div>
) : (
<Button onClick={handleCapture} disabled={isCapturing}>
<Camera className="h-4 w-4 mr-2" />
{countdown !== null
? `Capturing in ${countdown}...`
: isCapturing
? "Waiting for screen share..."
: "Take Screenshot"}
</Button>
<div className="flex flex-wrap gap-3">
<Button onClick={handleCapture} disabled={isCapturing}>
<Camera className="h-4 w-4 mr-2" />
{countdown !== null
? `Capturing in ${countdown}...`
: isCapturing
? "Waiting for screen share..."
: batch.length > 0
? "Take Another Screenshot"
: "Take Screenshot"}
</Button>
{batch.length > 0 && (
<>
<Button
onClick={handleSave}
disabled={!selectedPatient || isSaving || isDetecting}
title={!selectedPatient ? "Select a patient below first" : undefined}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : `Save All (${batch.length})`}
</Button>
<Button
variant="secondary"
onClick={handleAiDetectAndCheck}
disabled={isSaving || isDetecting}
>
<Sparkles className="h-4 w-4 mr-2" />
{isDetecting ? "Detecting..." : "AI Detect & Check Eligibility"}
</Button>
</>
)}
</div>
)}
</CardContent>
</Card>
@@ -271,7 +585,8 @@ export default function AiCopyAgentPage() {
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select the patient this screenshot belongs to.
Select the patient this screenshot belongs to, or leave unselected and use
"AI Detect &amp; Check Eligibility" to create one automatically.
</CardDescription>
</CardHeader>
<CardContent>
@@ -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 () => {