feat: Users AI Chat multi-step workflows with CDT lookup and alias management

- Add eligibility_by_id and check_and_claim intents to internal chat
- New cdt-lookup.ts: keyword search against fee schedule JSON (no LLM)
- New internal-chat-workflow.ts: deterministic orchestration — patient
  resolution, insurance siteKey derivation, CDT code mapping
- Custom CDT aliases stored per-user in DB (TwilioSettings JSON blob)
  with GET/PUT /api/ai/cdt-aliases endpoints
- Chatbot UI: new steps for eligibility-id-ready, check-and-claim-ready,
  and need-insurance-clarification with insurance picker
- Settings UI: CDT Aliases CRUD table with built-in alias reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-06-03 17:44:19 -04:00
parent 4274cd61dc
commit ba2882957a
8 changed files with 2357 additions and 133 deletions

View File

@@ -21,7 +21,10 @@ type Step =
| "eligibility-input"
| "eligibility-confirm"
| "ai-loading"
| "patient-found";
| "patient-found"
| "eligibility-id-ready"
| "check-and-claim-ready"
| "need-insurance-clarification";
interface Message {
id: number;
@@ -45,6 +48,15 @@ interface EligibilityData {
dobISO: string;
}
interface CheckAndClaimData {
patient: PatientResult | null;
memberId: string;
dob: string; // ISO YYYY-MM-DD
siteKey: string;
autoCheck: string;
matchedCodes: { code: string; description: string }[];
}
let msgCounter = 0;
function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message {
return { id: ++msgCounter, role, text, isLoading };
@@ -97,6 +109,9 @@ export function ChatbotButton() {
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
const [freeTextInput, setFreeTextInput] = useState("");
const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null);
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
const [, setLocation] = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(null);
@@ -134,6 +149,9 @@ export function ChatbotButton() {
setEligibilityData(null);
setFreeTextInput("");
setPatientResult(null);
setEligibilityIdData(null);
setCheckAndClaimData(null);
setClarificationData(null);
};
const handleClose = () => {
@@ -199,6 +217,37 @@ export function ChatbotButton() {
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
};
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
};
const handleEligibilityIdRun = () => {
if (!eligibilityIdData) return;
addMsg("user", "Check eligibility now");
addMsg("bot", "Opening the eligibility check page...");
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
};
const handleCheckAndClaimRun = () => {
if (!checkAndClaimData) return;
addMsg("user", "Run check & claim");
addMsg("bot", "Opening the eligibility check page...");
// Store claim codes so the eligibility page can offer auto-claim after ACTIVE result
sessionStorage.setItem(
"chatbot_claim_codes",
JSON.stringify({
codes: checkAndClaimData.matchedCodes,
siteKey: checkAndClaimData.siteKey,
patientId: checkAndClaimData.patient?.id ?? null,
memberId: checkAndClaimData.memberId,
dob: checkAndClaimData.dob,
})
);
prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck);
};
const handleFreeTextSubmit = async () => {
const text = freeTextInput.trim();
if (!text || step === "ai-loading") return;
@@ -227,6 +276,43 @@ export function ChatbotButton() {
return;
}
if (data.action === "eligibility_id_ready" && data.actionData) {
setEligibilityIdData({
memberId: data.actionData.memberId,
dob: data.actionData.dob,
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
patient: data.actionData.patient ?? null,
});
setStep("eligibility-id-ready");
return;
}
if (data.action === "check_and_claim_ready" && data.actionData) {
setCheckAndClaimData({
patient: data.actionData.patient ?? null,
memberId: data.actionData.memberId,
dob: data.actionData.dob,
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
matchedCodes: data.actionData.matchedCodes ?? [],
});
setStep("check-and-claim-ready");
return;
}
if (data.action === "need_insurance_clarification" && data.actionData) {
setClarificationData({
memberId: data.actionData.memberId,
dob: data.actionData.dob,
patient: data.actionData.patient ?? null,
procedureNames: data.actionData.procedureNames ?? [],
options: data.actionData.options ?? [],
});
setStep("need-insurance-clarification");
return;
}
setStep("menu");
} catch {
replaceLastMsg("Sorry, something went wrong. Please try again.");
@@ -241,7 +327,13 @@ export function ChatbotButton() {
}
};
const showFreeTextInput = step === "menu" || step === "ai-loading";
const showFreeTextInput =
step === "menu" ||
step === "ai-loading" ||
step === "patient-found" ||
step === "eligibility-id-ready" ||
step === "check-and-claim-ready" ||
step === "need-insurance-clarification";
return (
<>
@@ -416,6 +508,129 @@ export function ChatbotButton() {
</div>
)}
{/* Eligibility by ID ready */}
{step === "eligibility-id-ready" && eligibilityIdData && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 space-y-2">
{eligibilityIdData.patient && (
<p className="text-xs font-semibold text-blue-800">
{eligibilityIdData.patient.firstName} {eligibilityIdData.patient.lastName}
</p>
)}
<p className="text-xs text-blue-600">ID: {eligibilityIdData.memberId}</p>
<p className="text-xs text-gray-500">DOB: {eligibilityIdData.dob}</p>
{eligibilityIdData.patient?.insuranceProvider && (
<p className="text-xs text-gray-500">{eligibilityIdData.patient.insuranceProvider}</p>
)}
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90"
onClick={handleEligibilityIdRun}
>
<Stethoscope className="h-3 w-3 mr-1" />
Check Eligibility
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
Cancel
</Button>
</div>
</div>
)}
{/* Check & Claim ready */}
{step === "check-and-claim-ready" && checkAndClaimData && (
<div className="bg-teal-50 border border-teal-200 rounded-xl p-3 space-y-2">
{checkAndClaimData.patient && (
<p className="text-xs font-semibold text-teal-800">
{checkAndClaimData.patient.firstName} {checkAndClaimData.patient.lastName}
</p>
)}
<p className="text-xs text-teal-600">ID: {checkAndClaimData.memberId} · DOB: {checkAndClaimData.dob}</p>
{checkAndClaimData.matchedCodes.length > 0 && (
<div className="space-y-0.5">
<p className="text-xs font-medium text-teal-700">Claim after ACTIVE:</p>
{checkAndClaimData.matchedCodes.map((c) => (
<p key={c.code} className="text-xs text-gray-600 pl-2">
{c.code} {c.description}
</p>
))}
</div>
)}
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1 h-8 text-xs bg-teal-600 hover:bg-teal-700 text-white"
onClick={handleCheckAndClaimRun}
>
<Stethoscope className="h-3 w-3 mr-1" />
Check &amp; Claim
</Button>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
Cancel
</Button>
</div>
</div>
)}
{/* Need insurance clarification */}
{step === "need-insurance-clarification" && clarificationData && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-amber-800">Which insurance?</p>
<p className="text-xs text-gray-500">ID: {clarificationData.memberId}</p>
<div className="flex flex-col gap-1.5 pt-1">
{clarificationData.options.map((opt) => (
<button
key={opt}
className="text-left text-xs px-3 py-1.5 rounded-lg border border-amber-300 hover:bg-amber-100 transition-colors"
onClick={() => {
addMsg("user", opt);
addMsg("bot", "Thinking...", true);
setStep("ai-loading");
apiRequest("POST", "/api/ai/internal-chat", {
message: `check ${opt} for ${clarificationData.memberId}, ${clarificationData.dob}${clarificationData.procedureNames.length > 0 ? " and claim " + clarificationData.procedureNames.join(", ") : ""}`,
})
.then((r) => r.json())
.then((data) => {
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
if (data.action === "check_and_claim_ready" && data.actionData) {
setCheckAndClaimData({
patient: data.actionData.patient ?? null,
memberId: data.actionData.memberId,
dob: data.actionData.dob,
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
matchedCodes: data.actionData.matchedCodes ?? [],
});
setStep("check-and-claim-ready");
} else if (data.action === "eligibility_id_ready" && data.actionData) {
setEligibilityIdData({
memberId: data.actionData.memberId,
dob: data.actionData.dob,
siteKey: data.actionData.siteKey,
autoCheck: data.actionData.autoCheck,
patient: data.actionData.patient ?? null,
});
setStep("eligibility-id-ready");
} else {
setStep("menu");
}
})
.catch(() => {
replaceLastMsg("Sorry, something went wrong.");
setStep("menu");
});
}}
>
{opt}
</button>
))}
</div>
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={reset}>
Cancel
</Button>
</div>
)}
<div ref={messagesEndRef} />
</div>