From be90966f6e1d4d0eb9b3526bac2293d088f0570e Mon Sep 17 00:00:00 2001 From: ff Date: Tue, 2 Jun 2026 00:44:05 -0400 Subject: [PATCH] feat: add internal AI chat assistant and AI Input Agent page - Add Gemini-powered internal staff chatbot (free-text input in the upper-right bot panel): type "check MARIA GONZALES" to search patient and pre-fill eligibility, or "open claims" to navigate directly - Add /api/ai/internal-chat endpoint with LangGraph + Google Gemini classifier (intent: check_eligibility, find_patient, navigate_*) - Add Users AI Chat settings section in Settings > Advanced > AI Chat to configure a custom system prompt for the internal assistant - Store internal chat system prompt in existing twilioSettings JSON blob (no DB migration needed) - Add AI Input Agent sidebar entry and placeholder page describing planned keyboard-automation typing into Open Dental / Eaglesoft Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/ai/internal-chat-graph.ts | 69 ++++ apps/Backend/src/routes/ai-settings.ts | 149 +++++++++ apps/Backend/src/storage/twilio-storage.ts | 17 + apps/Frontend/src/App.tsx | 2 + .../src/components/layout/chatbot.tsx | 297 ++++++++++++------ .../src/components/layout/sidebar.tsx | 5 + .../settings/ai-chat-settings-card.tsx | 132 +++++++- .../src/pages/ai-input-agent-page.tsx | 137 ++++++++ 8 files changed, 707 insertions(+), 101 deletions(-) create mode 100644 apps/Backend/src/ai/internal-chat-graph.ts create mode 100644 apps/Frontend/src/pages/ai-input-agent-page.tsx diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts new file mode 100644 index 00000000..82f6c903 --- /dev/null +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -0,0 +1,69 @@ +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; + +export type InternalChatIntent = + | "check_eligibility" + | "find_patient" + | "navigate_claims" + | "navigate_schedule" + | "general"; + +export interface ChatClassification { + intent: InternalChatIntent; + patientName?: string; + fallbackReply: string; +} + +const BASE_SYSTEM_PROMPT = `You are an internal assistant for a dental office management system. +Staff members type natural language commands and you classify what they want. + +Respond ONLY with valid JSON (no markdown, no code fences) in this exact format: +{ + "intent": "", + "patientName": "", + "fallbackReply": "" +} + +Intents: +- check_eligibility: user wants to check insurance eligibility for a patient (e.g. "check MARIA", "verify insurance for John Smith", "eligibility GONZALES") +- find_patient: user wants to look up a patient record only (e.g. "find patient John", "look up Smith", "show me GONZALES record") +- navigate_claims: user wants to open the claims page +- navigate_schedule: user wants to open the appointments/schedule page +- general: anything else — answer helpfully based on dental office context + +Rules: +- Extract the full patient name as-is from the message for check_eligibility and find_patient +- Keep fallbackReply to 1-2 sentences max +- For navigate intents, fallbackReply should say "Opening the [page] page..."`; + +export async function classifyInternalChat( + message: string, + apiKey: string, + extraSystemPrompt?: string +): Promise { + const fallback: ChatClassification = { + intent: "general", + fallbackReply: "I can help you search for a patient, check eligibility, or navigate to claims or appointments.", + }; + + if (!apiKey) return fallback; + + const systemPrompt = extraSystemPrompt + ? `${BASE_SYSTEM_PROMPT}\n\nAdditional office context:\n${extraSystemPrompt}` + : BASE_SYSTEM_PROMPT; + + try { + const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); + const response = await llm.invoke([ + { role: "system", content: systemPrompt }, + { role: "user", content: message }, + ]); + + const raw = String(response.content).trim(); + const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim(); + const parsed = JSON.parse(jsonStr) as ChatClassification; + if (!parsed.intent || !parsed.fallbackReply) return fallback; + return parsed; + } catch { + return fallback; + } +} diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 53b70cf7..8187fa67 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from "express"; import { storage } from "../storage"; +import { classifyInternalChat } from "../ai/internal-chat-graph"; const router = express.Router(); @@ -97,4 +98,152 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise } }); +// GET /api/ai/internal-chat-settings +router.get("/internal-chat-settings", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const systemPrompt = await storage.getInternalChatSystemPrompt(userId); + return res.status(200).json({ systemPrompt }); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch internal chat settings", details: String(err) }); + } +}); + +// PUT /api/ai/internal-chat-settings +router.put("/internal-chat-settings", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { systemPrompt } = req.body; + if (typeof systemPrompt !== "string") return res.status(400).json({ message: "systemPrompt must be a string" }); + await storage.saveInternalChatSystemPrompt(userId, systemPrompt.trim()); + return res.status(200).json({ systemPrompt: systemPrompt.trim() }); + } catch (err) { + return res.status(500).json({ error: "Failed to save internal chat settings", details: String(err) }); + } +}); + +// POST /api/ai/internal-chat +router.post("/internal-chat", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { message } = req.body; + if (!message?.trim()) return res.status(400).json({ message: "message is required" }); + + const aiSettings = await storage.getAiSettings(userId); + if (!aiSettings?.apiKey) { + return res.status(200).json({ + reply: "AI is not configured. Please add a Google AI API key in Settings.", + }); + } + + const extraSystemPrompt = await storage.getInternalChatSystemPrompt(userId); + const classification = await classifyInternalChat(message.trim(), aiSettings.apiKey, extraSystemPrompt || undefined); + + // Handle navigation intents immediately + if (classification.intent === "navigate_claims") { + return res.status(200).json({ reply: classification.fallbackReply, action: "navigate", actionData: { url: "/claims" } }); + } + if (classification.intent === "navigate_schedule") { + return res.status(200).json({ reply: classification.fallbackReply, action: "navigate", actionData: { url: "/appointments" } }); + } + + // Handle patient intents — search DB + if (classification.intent === "check_eligibility" || classification.intent === "find_patient") { + const name = classification.patientName?.trim(); + if (!name) { + return res.status(200).json({ reply: "Please include the patient's name in your message." }); + } + + const patients = await storage.searchPatients({ + filters: { + OR: [ + { firstName: { contains: name, mode: "insensitive" } }, + { lastName: { contains: name, mode: "insensitive" } }, + { + AND: name.split(/\s+/).map((part: string) => ({ + OR: [ + { firstName: { contains: part, mode: "insensitive" } }, + { lastName: { contains: part, mode: "insensitive" } }, + ], + })), + }, + ], + }, + limit: 5, + offset: 0, + }); + + if (!patients || patients.length === 0) { + return res.status(200).json({ + reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`, + }); + } + + const patient = patients[0]!; + const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); + + if (classification.intent === "find_patient") { + const ins = patient.insuranceProvider ? ` · ${patient.insuranceProvider}` : ""; + const id = patient.insuranceId ? ` (ID: ${patient.insuranceId})` : ""; + return res.status(200).json({ + reply: `Found: ${fullName}${ins}${id}`, + action: "show_patient", + actionData: { + patient: { + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + insuranceId: patient.insuranceId ?? null, + insuranceProvider: patient.insuranceProvider ?? null, + dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null, + }, + }, + }); + } + + // check_eligibility + if (!patient.insuranceId) { + return res.status(200).json({ + reply: `Found ${fullName} but no Member ID is on file. Please add their insurance info first.`, + action: "show_patient", + actionData: { + patient: { + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + insuranceId: null, + insuranceProvider: patient.insuranceProvider ?? null, + dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null, + }, + }, + }); + } + + return res.status(200).json({ + reply: `Found ${fullName}. Ready to check eligibility.`, + action: "check_eligibility_prefill", + actionData: { + patient: { + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + insuranceId: patient.insuranceId, + insuranceProvider: patient.insuranceProvider ?? null, + dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null, + }, + }, + }); + } + + // General intent — return Gemini's reply + return res.status(200).json({ reply: classification.fallbackReply }); + } catch (err) { + return res.status(500).json({ error: "Internal chat error", details: String(err) }); + } +}); + export default router; diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts index 669b3777..86ac5409 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -111,6 +111,23 @@ export const twilioStorage = { }); }, + async getInternalChatSystemPrompt(userId: number): Promise { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const all = (settings?.templates as Record) || {}; + return all["_internal_chat_system_prompt"] ?? ""; + }, + + async saveInternalChatSystemPrompt(userId: number, prompt: string): Promise { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const existing = (settings?.templates as Record) || {}; + const updated = { ...existing, "_internal_chat_system_prompt": prompt }; + await db.twilioSettings.upsert({ + where: { userId }, + update: { templates: updated }, + create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated }, + }); + }, + async getRecentCommunicationsByUser(userId: number, limit = 20) { return db.communication.findMany({ where: { patient: { userId } }, diff --git a/apps/Frontend/src/App.tsx b/apps/Frontend/src/App.tsx index 7ebaf185..4091a641 100755 --- a/apps/Frontend/src/App.tsx +++ b/apps/Frontend/src/App.tsx @@ -29,6 +29,7 @@ const ReportsPage = lazy(() => import("./pages/reports-page")); const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page")); const JobMonitorPage = lazy(() => import("./pages/job-monitor-page")); const ChartPage = lazy(() => import("./pages/chart-page")); +const AiInputAgentPage = lazy(() => import("./pages/ai-input-agent-page")); const DentalShoppingSearchTagPage = lazy(() => import("./pages/dental-shopping-search-tag-page")); const DentalShoppingLoginInfoPage = lazy(() => import("./pages/dental-shopping-login-info-page")); const ActivationPage = lazy(() => import("./pages/activation-page")); @@ -64,6 +65,7 @@ function Router() { /> } /> } /> + } /> } /> } /> } adminOnly /> diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index f95d4d07..b1e3eab6 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -7,29 +7,47 @@ import { Calendar, FileText, MessageSquare, + Send, + Loader2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { useLocation } from "wouter"; import { cn } from "@/lib/utils"; +import { apiRequest } from "@/lib/queryClient"; -type Step = "menu" | "eligibility-input" | "eligibility-confirm"; +type Step = + | "menu" + | "eligibility-input" + | "eligibility-confirm" + | "ai-loading" + | "patient-found"; interface Message { id: number; role: "bot" | "user"; text: string; + isLoading?: boolean; +} + +interface PatientResult { + id: number; + firstName: string; + lastName: string; + insuranceId: string | null; + insuranceProvider: string | null; + dateOfBirth: string | null; } interface EligibilityData { memberId: string; - dob: string; // MM/DD/YYYY display format - dobISO: string; // YYYY-MM-DD for storage + dob: string; + dobISO: string; } let msgCounter = 0; -function makeMsg(role: "bot" | "user", text: string): Message { - return { id: ++msgCounter, role, text }; +function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message { + return { id: ++msgCounter, role, text, isLoading }; } function getAutoCheck(dobISO: string): "mh" | "cmsp" { @@ -44,7 +62,6 @@ function getAutoCheck(dobISO: string): "mh" | "cmsp" { function parseEligibilityInput( raw: string ): { memberId: string; display: string; iso: string } | null { - // Find a date anywhere in the text (MM/DD/YYYY or MM-DD-YYYY) const dateMatch = raw.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\b/); if (!dateMatch) return null; const m = dateMatch[1]; @@ -56,7 +73,6 @@ function parseEligibilityInput( const year = parseInt(y, 10); if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900) return null; - // Remove the matched date, then collect remaining alphanumeric chars as member ID const withoutDate = raw.replace(dateMatch[0], ""); const memberId = (withoutDate.match(/[a-zA-Z0-9]/g) ?? []).join(""); if (!memberId) return null; @@ -78,11 +94,13 @@ export function ChatbotButton() { const [messages, setMessages] = useState(INITIAL_MESSAGES); const [pasteInput, setPasteInput] = useState(""); const [parseError, setParseError] = useState(""); - const [eligibilityData, setEligibilityData] = - useState(null); + const [eligibilityData, setEligibilityData] = useState(null); + const [freeTextInput, setFreeTextInput] = useState(""); + const [patientResult, setPatientResult] = useState(null); const [, setLocation] = useLocation(); const messagesEndRef = useRef(null); const pasteRef = useRef(null); + const freeTextRef = useRef(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -92,10 +110,21 @@ export function ChatbotButton() { if (step === "eligibility-input") { setTimeout(() => pasteRef.current?.focus(), 50); } + if (step === "menu") { + setTimeout(() => freeTextRef.current?.focus(), 50); + } }, [step]); - const addMsg = (role: "bot" | "user", text: string) => - setMessages((prev) => [...prev, makeMsg(role, text)]); + const addMsg = (role: "bot" | "user", text: string, isLoading = false) => + setMessages((prev) => [...prev, makeMsg(role, text, isLoading)]); + + const replaceLastMsg = (text: string) => + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last) next[next.length - 1] = { ...last, text, isLoading: false }; + return next; + }); const reset = () => { setStep("menu"); @@ -103,6 +132,8 @@ export function ChatbotButton() { setPasteInput(""); setParseError(""); setEligibilityData(null); + setFreeTextInput(""); + setPatientResult(null); }; const handleClose = () => { @@ -110,31 +141,15 @@ export function ChatbotButton() { reset(); }; - const handleOptionSelect = ( - option: "eligibility" | "schedule" | "claims" | "chat" - ) => { + const handleOptionSelect = (option: "eligibility" | "schedule" | "claims") => { if (option === "schedule") { addMsg("user", "Schedule an appointment"); addMsg("bot", "Opening the appointments page..."); - setTimeout(() => { - setLocation("/appointments"); - setOpen(false); - reset(); - }, 600); + setTimeout(() => { setLocation("/appointments"); setOpen(false); reset(); }, 600); } else if (option === "claims") { addMsg("user", "View claims"); addMsg("bot", "Opening the claims page..."); - setTimeout(() => { - setLocation("/claims"); - setOpen(false); - reset(); - }, 600); - } else if (option === "chat") { - addMsg("user", "General chat"); - addMsg( - "bot", - "This feature is coming soon! For now, use the quick options below to navigate." - ); + setTimeout(() => { setLocation("/claims"); setOpen(false); reset(); }, 600); } else if (option === "eligibility") { addMsg("user", "Check Eligibility"); addMsg("bot", "Please enter the patient's Member ID and Date of Birth:"); @@ -145,25 +160,14 @@ export function ChatbotButton() { const handleEligibilitySubmit = () => { const parsed = parseEligibilityInput(pasteInput); if (!parsed) { - setParseError( - "Couldn't find both a Member ID and a date. Try: 123456789 01/15/1985" - ); + setParseError("Couldn't find both a Member ID and a date. Try: 123456789 01/15/1985"); return; } setParseError(""); - - const data: EligibilityData = { - memberId: parsed.memberId, - dob: parsed.display, - dobISO: parsed.iso, - }; + const data: EligibilityData = { memberId: parsed.memberId, dob: parsed.display, dobISO: parsed.iso }; setEligibilityData(data); - addMsg("user", pasteInput.trim()); - addMsg( - "bot", - `Ready to check MassHealth eligibility for:\n• Member ID: ${parsed.memberId}\n• Date of Birth: ${parsed.display}\n\nShall I proceed?` - ); + addMsg("bot", `Ready to check MassHealth eligibility for:\n• Member ID: ${parsed.memberId}\n• Date of Birth: ${parsed.display}\n\nShall I proceed?`); setStep("eligibility-confirm"); }; @@ -171,22 +175,74 @@ export function ChatbotButton() { if (!eligibilityData) return; addMsg("user", "Yes, check now"); addMsg("bot", "Opening the eligibility check page with this patient..."); - sessionStorage.setItem( - "chatbot_eligibility", - JSON.stringify({ - memberId: eligibilityData.memberId, - dob: eligibilityData.dobISO, - autoCheck: getAutoCheck(eligibilityData.dobISO), - }) - ); + sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ + memberId: eligibilityData.memberId, + dob: eligibilityData.dobISO, + autoCheck: getAutoCheck(eligibilityData.dobISO), + })); window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); - setTimeout(() => { - setLocation("/insurance-status"); - setOpen(false); - reset(); - }, 600); + setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600); }; + const handleEligibilityFromPatient = () => { + if (!patientResult) return; + addMsg("user", "Check eligibility now"); + addMsg("bot", "Opening the eligibility check page..."); + if (patientResult.insuranceId && patientResult.dateOfBirth) { + sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ + memberId: patientResult.insuranceId, + dob: patientResult.dateOfBirth, + autoCheck: getAutoCheck(patientResult.dateOfBirth), + })); + window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); + } + setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600); + }; + + const handleFreeTextSubmit = async () => { + const text = freeTextInput.trim(); + if (!text || step === "ai-loading") return; + setFreeTextInput(""); + addMsg("user", text); + addMsg("bot", "Thinking...", true); + setStep("ai-loading"); + + try { + const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text }); + const data = await res.json(); + + replaceLastMsg(data.reply ?? "Sorry, I couldn't process that."); + + if (data.action === "navigate" && data.actionData?.url) { + setTimeout(() => { setLocation(data.actionData.url); setOpen(false); reset(); }, 800); + return; + } + + if ( + (data.action === "check_eligibility_prefill" || data.action === "show_patient") && + data.actionData?.patient + ) { + setPatientResult(data.actionData.patient); + setStep("patient-found"); + return; + } + + setStep("menu"); + } catch { + replaceLastMsg("Sorry, something went wrong. Please try again."); + setStep("menu"); + } + }; + + const handleFreeTextKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleFreeTextSubmit(); + } + }; + + const showFreeTextInput = step === "menu" || step === "ai-loading"; + return ( <> - {/* Messages + controls */} + {/* Messages */}
{messages.map((msg) => (
- {msg.text} + {msg.isLoading ? ( + + + Thinking... + + ) : ( + msg.text + )}
))} - {/* Step: menu */} + {/* Quick-action buttons (menu step) */} {step === "menu" && (
-
)} - {/* Step: eligibility input */} + {/* Eligibility manual input */} {step === "eligibility-input" && (
- +