feat: AI chat system with LangGraph, multi-step patient flows, and appointment rescheduling

- Add floating chat window Hand-off to AI toggle (per-patient) and after-hours AI toggle (global)
- Add LangGraph-powered appointment reminder flow: AI introduces itself, classifies YES/NO, handles confirmation with appointment date/time
- Add multi-step rescheduling flow: ASAP vs next week, tomorrow offer, Mon/Tue/Wed picker, morning/afternoon time slot — automatically updates appointment in DB
- Add new patient / after-hours flow: new vs existing patient, dental insurance check, MassHealth Selenium eligibility check (auto-uses saved member ID + DOB for existing patients), self-pay fallback
- Add AI Chat Settings page (Settings → Advanced) with editable greeting templates and LangGraph flow diagrams for both reminder and new-patient flows
- Add Schedule a New Patient template option in chat window, starts new-patient conversation flow
- Add GET/PUT endpoints for AI handoff, after-hours handoff, and AI chat templates
- Add multilingual support (7 languages) across all AI reply nodes with LLM generation and hardcoded fallbacks
- Add pending reschedule in-memory store and conversation stage tracking across all flows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-07 23:21:06 -04:00
parent 86dd685342
commit 9908e5b5fd
317 changed files with 6533 additions and 274 deletions

View File

@@ -11,7 +11,8 @@ import {
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Send, ArrowLeft, FileText, Globe } from "lucide-react";
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import type { Patient, Communication } from "@repo/db/types";
import { format, isToday, isYesterday, parseISO } from "date-fns";
import { es, pt, zhCN, zhTW, ar, fr } from "date-fns/locale";
@@ -264,6 +265,37 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
: "English"
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [handOffToAI, setHandOffToAI] = useState(true);
const [pendingStartFlow, setPendingStartFlow] = useState<"new_patient" | null>(null);
useQuery<{ enabled: boolean }>({
queryKey: ["/api/twilio/ai-handoff", patient.id],
queryFn: async () => {
const res = await apiRequest("GET", `/api/twilio/ai-handoff/${patient.id}`);
return res.json();
},
onSuccess: (data: { enabled: boolean }) => setHandOffToAI(data.enabled),
} as any);
const { data: aiChatTemplates } = useQuery<{ newPatientGreeting: string } | null>({
queryKey: ["/api/ai/chat-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/chat-templates");
if (!res.ok) return null;
return res.json();
},
staleTime: 60_000,
});
const handoffMutation = useMutation({
mutationFn: async (enabled: boolean) =>
apiRequest("PUT", `/api/twilio/ai-handoff/${patient.id}`, { enabled }),
});
const handleHandoffToggle = (enabled: boolean) => {
setHandOffToAI(enabled);
handoffMutation.mutate(enabled);
};
const { data: officeContact } = useQuery<OfficeContact | null>({
queryKey: ["/api/office-contact"],
@@ -290,9 +322,11 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
to: patient.phone,
message,
patientId: patient.id,
...(pendingStartFlow ? { startFlow: pendingStartFlow } : {}),
}),
onSuccess: () => {
setMessageText("");
setPendingStartFlow(null);
queryClient.invalidateQueries({ queryKey: ["/api/patients", patient.id, "communications"] });
toast({ title: "Message sent", description: "Your message has been sent successfully." });
},
@@ -395,14 +429,29 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
<Select
value=""
onValueChange={(key) => {
const tpl = templates.find((t) => t.key === key);
if (tpl) setMessageText(tpl.body);
if (key === "__new_patient__") {
const greeting = aiChatTemplates?.newPatientGreeting ||
"Hi! My name is Lisa, the dedicated AI assistant at our dental office. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?";
setMessageText(greeting);
setPendingStartFlow("new_patient");
} else {
const tpl = templates.find((t) => t.key === key);
if (tpl) { setMessageText(tpl.body); setPendingStartFlow(null); }
}
}}
>
<SelectTrigger className="h-7 text-xs border-dashed w-[180px]">
<SelectValue placeholder="Use a template…" />
</SelectTrigger>
<SelectContent>
{/* New patient scheduling — uses AI New Patient Greeting */}
<SelectItem value="__new_patient__" className="text-xs font-medium text-primary">
<span className="flex items-center gap-1.5">
<UserPlus className="h-3 w-3" />
Schedule a New Patient
</span>
</SelectItem>
<div className="my-1 border-t" />
{templates.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
@@ -411,6 +460,25 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
</SelectContent>
</Select>
</div>
{/* New-patient flow indicator */}
{pendingStartFlow === "new_patient" && (
<div className="flex items-center gap-1 text-xs text-primary bg-primary/5 border border-primary/20 rounded px-2 py-0.5">
<UserPlus className="h-3 w-3" />
New patient flow
</div>
)}
{/* AI handoff toggle */}
<div className="flex items-center gap-1.5 ml-auto">
<Bot className={`h-3.5 w-3.5 flex-shrink-0 ${handOffToAI ? "text-primary" : "text-muted-foreground"}`} />
<span className="text-xs text-muted-foreground">Hand off to AI</span>
<Switch
checked={handOffToAI}
onCheckedChange={handleHandoffToggle}
className="scale-75 origin-left"
/>
</div>
</div>
</div>