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:
@@ -2,44 +2,80 @@ import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
|
||||
const GraphState = Annotation.Root({
|
||||
message: Annotation<string>(),
|
||||
intent: Annotation<string>(),
|
||||
reply: Annotation<string>(),
|
||||
message: Annotation<string>(),
|
||||
intent: Annotation<string>(),
|
||||
reply: Annotation<string>(),
|
||||
language: Annotation<string>(),
|
||||
appointmentDatetime: Annotation<string>(),
|
||||
});
|
||||
|
||||
type GraphStateType = typeof GraphState.State;
|
||||
|
||||
// Keyword-based intent classifier — fast and deterministic for yes/no
|
||||
// ── Intent classifier — multilingual yes/no keywords ─────────────────────────
|
||||
|
||||
function classifyNode(state: GraphStateType) {
|
||||
const text = state.message.toLowerCase().trim();
|
||||
|
||||
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely)\b/;
|
||||
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going)\b/;
|
||||
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|confirmo|de acuerdo|seguro|estaré|sim|confirmado|com certeza|好的|确认|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/;
|
||||
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going|no puedo|no podré|cancelar|reprogramar|ocupado|lo siento|não posso|não vou|reagendar|占线|无法|取消|لا|لا أستطيع|إلغاء|pa kapab|pa ka)\b/;
|
||||
|
||||
if (yesPatterns.test(text)) return { intent: "yes" };
|
||||
if (noPatterns.test(text)) return { intent: "no" };
|
||||
if (noPatterns.test(text)) return { intent: "no" };
|
||||
return { intent: "other" };
|
||||
}
|
||||
|
||||
function routeByIntent(state: GraphStateType): string {
|
||||
if (state.intent === "yes") return "thank_you";
|
||||
if (state.intent === "no") return "reschedule";
|
||||
if (state.intent === "yes") return "confirm";
|
||||
if (state.intent === "no") return "reschedule";
|
||||
return END;
|
||||
}
|
||||
|
||||
async function thankYouNode(state: GraphStateType, config: any) {
|
||||
// ── Confirmation fallbacks (with appointment datetime) ────────────────────────
|
||||
|
||||
function buildConfirmFallback(lang: string, apptDatetime: string): string {
|
||||
const appt = apptDatetime || "your scheduled appointment";
|
||||
const map: Record<string, string> = {
|
||||
English: `Thank you for your confirmation! We look forward to seeing you on ${appt}.`,
|
||||
Spanish: `¡Gracias por confirmar! Le esperamos el ${appt}.`,
|
||||
Portuguese: `Obrigado por confirmar! Aguardamos a sua visita em ${appt}.`,
|
||||
Mandarin: `感谢您的确认!我们期待在 ${appt} 见到您。`,
|
||||
Cantonese: `感謝您的確認!我們期待在 ${appt} 見到您。`,
|
||||
Arabic: `شكراً على تأكيدك! نتطلع إلى رؤيتك في ${appt}.`,
|
||||
"Haitian Creole": `Mèsi dèske ou konfime! N'ap tann ou ${appt}.`,
|
||||
};
|
||||
return map[lang] ?? map["English"]!;
|
||||
}
|
||||
|
||||
// ── Reschedule fallbacks ──────────────────────────────────────────────────────
|
||||
|
||||
const RESCHEDULE_FALLBACKS: Record<string, string> = {
|
||||
English: "It is understandable! Would you like to reschedule?",
|
||||
Spanish: "¡Lo entendemos! ¿Le gustaría reprogramar su cita?",
|
||||
Portuguese: "Entendemos! Gostaria de reagendar a sua consulta?",
|
||||
Mandarin: "我们理解!您想重新安排预约吗?",
|
||||
Cantonese: "我們理解!您想重新安排預約嗎?",
|
||||
Arabic: "نتفهم ذلك! هل تود إعادة جدولة موعدك؟",
|
||||
"Haitian Creole": "Nou konprann! Èske ou ta renmen repwograme randevou ou?",
|
||||
};
|
||||
|
||||
// ── LangGraph nodes ───────────────────────────────────────────────────────────
|
||||
|
||||
async function confirmNode(state: GraphStateType, config: any) {
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const fallback = "Thank you for confirming your appointment! We look forward to seeing you.";
|
||||
const lang = state.language || "English";
|
||||
const appt = state.appointmentDatetime || "";
|
||||
const fallback = buildConfirmFallback(lang, appt);
|
||||
|
||||
if (!apiKey) return { reply: fallback };
|
||||
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const apptClause = appt ? ` Their appointment is on ${appt}.` : "";
|
||||
const response = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking a patient who just confirmed their appointment. Do not add any formatting or extra text.",
|
||||
`You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking the patient for confirming their appointment and reminding them of the date and time.${apptClause} You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`,
|
||||
},
|
||||
{ role: "user", content: `Patient replied: "${state.message}"` },
|
||||
]);
|
||||
@@ -51,7 +87,8 @@ async function thankYouNode(state: GraphStateType, config: any) {
|
||||
|
||||
async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const fallback = "We understand! Our assistant will contact you shortly to help reschedule.";
|
||||
const lang = state.language || "English";
|
||||
const fallback = RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!;
|
||||
|
||||
if (!apiKey) return { reply: fallback };
|
||||
|
||||
@@ -61,7 +98,7 @@ async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a friendly dental office assistant. Write a short, empathetic SMS reply (1-2 sentences max) to a patient who can't make their appointment. Tell them an assistant will contact them soon to reschedule. Do not add any formatting or extra text.",
|
||||
`You are a friendly dental office assistant. The patient cannot make their appointment. Write a short, empathetic SMS reply (1 sentence max) that says it is understandable and asks if they would like to reschedule. You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`,
|
||||
},
|
||||
{ role: "user", content: `Patient replied: "${state.message}"` },
|
||||
]);
|
||||
@@ -71,30 +108,34 @@ async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Graph ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const graph = new StateGraph(GraphState)
|
||||
.addNode("classify", classifyNode)
|
||||
.addNode("thank_you", thankYouNode)
|
||||
.addNode("classify", classifyNode)
|
||||
.addNode("confirm", confirmNode)
|
||||
.addNode("reschedule", rescheduleNode)
|
||||
.addEdge(START, "classify")
|
||||
.addConditionalEdges("classify", routeByIntent, {
|
||||
thank_you: "thank_you",
|
||||
confirm: "confirm",
|
||||
reschedule: "reschedule",
|
||||
[END]: END,
|
||||
[END]: END,
|
||||
})
|
||||
.addEdge("thank_you", END)
|
||||
.addEdge("confirm", END)
|
||||
.addEdge("reschedule", END)
|
||||
.compile();
|
||||
|
||||
export async function runReminderGraph(
|
||||
patientMessage: string,
|
||||
apiKey: string
|
||||
patientMessage: string,
|
||||
apiKey: string,
|
||||
language = "English",
|
||||
appointmentDatetime = ""
|
||||
): Promise<{ reply: string | null; intent: string | null }> {
|
||||
const result = await graph.invoke(
|
||||
{ message: patientMessage, intent: "", reply: "" },
|
||||
{ message: patientMessage, intent: "", reply: "", language, appointmentDatetime },
|
||||
{ configurable: { apiKey } }
|
||||
);
|
||||
return {
|
||||
reply: result.reply || null,
|
||||
reply: result.reply || null,
|
||||
intent: result.intent || null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user