diff --git a/apps/Backend/src/ai/aiHandoffStore.ts b/apps/Backend/src/ai/aiHandoffStore.ts new file mode 100644 index 00000000..33e4e961 --- /dev/null +++ b/apps/Backend/src/ai/aiHandoffStore.ts @@ -0,0 +1,95 @@ +// In-memory store for per-patient AI handoff toggle, conversation stage, +// and per-user after-hours handoff toggle. +// Conversation key: `${userId}:${patientId}` + +export type ConversationStage = + | "initial" // default — no active conversation + | "reminder_initial" // office sent a reminder, waiting for first patient reply + | "greeted" // reminder intro sent, waiting for yes/no + | "done" // conversation complete + | "new_patient_greeted" // new-patient greeting sent, waiting for patient intent + | "asked_new_or_existing" // AI asked "new or existing patient?" + | "asked_new_patient_insurance" // AI asked new patient about insurance + | "asked_existing_insurance" // AI asked existing patient about same insurance + | "asked_appointment_time" // AI asked when they'd like to come + | "awaiting_masshealth_info" // AI asked for Member ID + DOB, waiting for reply + | "asked_appointment_preference" // Selenium: ACTIVE — AI asked check-up vs problem + | "asked_self_pay" // Selenium: INACTIVE — AI asked if self-pay exam + | "asked_reschedule_confirm" // AI asked "Would you like to reschedule?" + | "asked_reschedule_preference" // AI asked ASAP vs next week + | "asked_reschedule_asap" // AI asked "Can you come tomorrow?" + | "asked_reschedule_next_week" // AI offered Mon/Tue/Wed next week + | "asked_reschedule_time"; // Day confirmed — AI asked morning or afternoon + +const handoffStore = new Map(); +const stageStore = new Map(); +const afterHoursStore = new Map(); // keyed by userId + +function convKey(userId: number, patientId: number): string { + return `${userId}:${patientId}`; +} + +// ── Per-patient handoff toggle (default ON) ─────────────────────────────────── + +export function getHandoff(userId: number, patientId: number): boolean { + const k = convKey(userId, patientId); + return handoffStore.has(k) ? handoffStore.get(k)! : true; +} + +export function setHandoff(userId: number, patientId: number, enabled: boolean): void { + handoffStore.set(convKey(userId, patientId), enabled); +} + +// ── Per-user after-hours handoff toggle (default ON) ───────────────────────── + +export function getAfterHoursHandoff(userId: number): boolean { + return afterHoursStore.has(userId) ? afterHoursStore.get(userId)! : true; +} + +export function setAfterHoursHandoff(userId: number, enabled: boolean): void { + afterHoursStore.set(userId, enabled); +} + +// ── Conversation stage ──────────────────────────────────────────────────────── + +export function getStage(userId: number, patientId: number): ConversationStage { + return stageStore.get(convKey(userId, patientId)) ?? "initial"; +} + +export function setStage(userId: number, patientId: number, stage: ConversationStage): void { + stageStore.set(convKey(userId, patientId), stage); +} + +// Called when office sends an outbound reminder — marks next patient reply +// as the start of a reminder conversation. +export function resetConversation(userId: number, patientId: number): void { + stageStore.set(convKey(userId, patientId), "reminder_initial"); +} + +// Called when office sends the new-patient greeting — marks next patient reply +// as the start of the new-patient conversation flow. +export function startNewPatientConversation(userId: number, patientId: number): void { + stageStore.set(convKey(userId, patientId), "new_patient_greeted"); +} + +// ── Pending reschedule data ─────────────────────────────────────────────────── +// Holds the confirmed new date while AI waits for a time-slot answer. + +interface PendingReschedule { + newDate: Date; // JS Date for the new appointment day (midnight UTC) + dayLabel: string; // human-readable, e.g. "Tuesday, May 19" +} + +const pendingRescheduleStore = new Map(); + +export function setPendingReschedule(userId: number, patientId: number, data: PendingReschedule): void { + pendingRescheduleStore.set(convKey(userId, patientId), data); +} + +export function getPendingReschedule(userId: number, patientId: number): PendingReschedule | undefined { + return pendingRescheduleStore.get(convKey(userId, patientId)); +} + +export function clearPendingReschedule(userId: number, patientId: number): void { + pendingRescheduleStore.delete(convKey(userId, patientId)); +} diff --git a/apps/Backend/src/ai/new-patient-graph.ts b/apps/Backend/src/ai/new-patient-graph.ts new file mode 100644 index 00000000..52eccc85 --- /dev/null +++ b/apps/Backend/src/ai/new-patient-graph.ts @@ -0,0 +1,435 @@ +import { StateGraph, END, START, Annotation } from "@langchain/langgraph"; +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import type { ConversationStage } from "./aiHandoffStore"; + +// ── Graph state ─────────────────────────────────────────────────────────────── + +const GraphState = Annotation.Root({ + message: Annotation(), + stage: Annotation(), + intent: Annotation(), + reply: Annotation(), + language: Annotation(), + nextStage: Annotation(), +}); + +type GraphStateType = typeof GraphState.State; + +// ── Transfer-to-staff fallback (multilingual) ───────────────────────────────── + +const TRANSFER: Record = { + English: "Thank you for reaching out! Our office staff will assist you shortly.", + Spanish: "¡Gracias por comunicarse! Un miembro del personal de la oficina le atenderá en breve.", + Portuguese: "Obrigado pelo contato! Nossa equipe entrará em contato em breve.", + Mandarin: "感谢您的联系!我们的工作人员将很快为您提供帮助。", + Cantonese: "感謝您的聯繫!我們的工作人員將很快為您提供幫助。", + Arabic: "شكراً لتواصلك! سيتواصل معك أحد موظفي المكتب قريباً.", + "Haitian Creole": "Mèsi dèske ou kontakte nou! Yon manm ekip biwo a pral ede ou byento.", +}; + +function transferMsg(lang: string): string { + return TRANSFER[lang] ?? TRANSFER["English"]!; +} + +// ── Intent classifiers ──────────────────────────────────────────────────────── + +function wantsAppointment(text: string): boolean { + return /appointment|schedule|cleaning|checkup|teeth|tooth|cavity|pain|dental|filling|crown|whitening|x-ray|exam|hygienist|dentist/i.test(text); +} + +function isNewPatient(text: string): boolean { + return /new patient|first time|first visit|never been|brand new|haven't been|i am new/i.test(text); +} + +function isExistingPatient(text: string): boolean { + return /existing|been there|have been|already|before|i have been|returning|came before|i was there/i.test(text); +} + +function hasMassHealth(text: string): boolean { + return /masshealth|mass health|medicaid|masscare/i.test(text); +} + +function hasOtherInsurance(text: string): boolean { + return /blue cross|delta dental|cigna|aetna|united|metlife|guardian|humana|tufts|harvard pilgrim|bmchp|yes|i have|my insurance|i do/i.test(text); +} + +function hasNoInsurance(text: string): boolean { + return /no insurance|uninsured|self.pay|self pay|i don't|don't have|no i don't|i have no/i.test(text); +} + +function sameInsurance(text: string): boolean { + return /yes|same|still have|haven't changed|no change/i.test(text); +} + +function changedInsurance(text: string): boolean { + return /no|changed|different|new insurance|switched|lost|expired/i.test(text); +} + +// ── LLM reply helper ────────────────────────────────────────────────────────── + +async function llmReply( + system: string, + userMsg: string, + fallback: string, + apiKey: string +): Promise { + try { + const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); + const res = await llm.invoke([ + { role: "system", content: system }, + { role: "user", content: userMsg }, + ]); + return String(res.content).trim() || fallback; + } catch { + return fallback; + } +} + +// ── Graph nodes ─────────────────────────────────────────────────────────────── + +// Classify intent based on current stage +function classifyNode(state: GraphStateType) { + const text = state.message.toLowerCase(); + const stage = state.stage as ConversationStage; + + let intent = "other"; + + if (stage === "new_patient_greeted") { + intent = wantsAppointment(text) ? "wants_appointment" : "other"; + } else if (stage === "asked_new_or_existing") { + if (isNewPatient(text)) intent = "new_patient"; + else if (isExistingPatient(text)) intent = "existing_patient"; + } else if (stage === "asked_new_patient_insurance") { + if (hasMassHealth(text)) intent = "masshealth"; + else if (hasNoInsurance(text)) intent = "no_insurance"; + else if (hasOtherInsurance(text)) intent = "other_insurance"; + } else if (stage === "asked_existing_insurance") { + if (sameInsurance(text)) intent = "same_insurance"; + else if (changedInsurance(text)) intent = "changed_insurance"; + } else if (stage === "asked_appointment_time") { + intent = "appointment_time"; + } else if (stage === "asked_appointment_preference") { + intent = "appointment_preference_reply"; + } else if (stage === "asked_self_pay") { + intent = "self_pay_reply"; + } + + return { intent }; +} + +function routeNode(state: GraphStateType): string { + const stage = state.stage as ConversationStage; + const intent = state.intent; + + if (stage === "new_patient_greeted") return intent === "wants_appointment" ? "ask_new_or_existing" : "transfer"; + if (stage === "asked_new_or_existing") return intent === "new_patient" ? "ask_new_patient_insurance" : intent === "existing_patient" ? "ask_existing_insurance" : "transfer"; + if (stage === "asked_new_patient_insurance") return intent === "masshealth" ? "ask_masshealth_info" : intent === "no_insurance" ? "ask_appointment_time" : "transfer"; + if (stage === "asked_existing_insurance") return intent === "same_insurance" ? "ask_appointment_time" : "transfer"; + if (stage === "asked_appointment_time") return "acknowledge_appointment_time"; + if (stage === "asked_appointment_preference") return "handle_appointment_preference"; + if (stage === "asked_self_pay") return "handle_self_pay"; + + return "transfer"; +} + +// ── Response nodes ──────────────────────────────────────────────────────────── + +async function askNewOrExistingNode(state: GraphStateType, config: any) { + const lang = state.language || "English"; + const apiKey: string | undefined = config?.configurable?.apiKey; + + const fallbacks: Record = { + English: "Are you a new patient or an existing patient?", + Spanish: "¿Es usted un paciente nuevo o ya ha visitado nuestra clínica antes?", + Portuguese: "Você é um paciente novo ou já veio ao nosso consultório antes?", + Mandarin: "您是新患者还是现有患者?", + Cantonese: "您係新病人定係舊病人?", + Arabic: "هل أنت مريض جديد أم مريض حالي؟", + "Haitian Creole": "Èske ou se yon nouvo pasyan oswa yon pasyan egzistan?", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. Ask the patient in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`, + `Patient wants an appointment. Ask if new or existing.`, + fallback, apiKey + ) + : fallback; + + return { reply, nextStage: "asked_new_or_existing" }; +} + +async function askNewPatientInsuranceNode(state: GraphStateType, config: any) { + const lang = state.language || "English"; + const apiKey: string | undefined = config?.configurable?.apiKey; + + const fallbacks: Record = { + English: "Do you have any dental insurance?", + Spanish: "¿Tiene seguro dental?", + Portuguese: "Você tem plano odontológico?", + Mandarin: "您有牙科保险吗?", + Cantonese: "您有牙科保險嗎?", + Arabic: "هل لديك تأمين أسنان؟", + "Haitian Creole": "Èske ou gen asirans dantè?", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. Ask the new patient in ${lang} if they have dental insurance. One sentence, no formatting.`, + `New patient confirmed. Ask about insurance.`, + fallback, apiKey + ) + : fallback; + + return { reply, nextStage: "asked_new_patient_insurance" }; +} + +async function askExistingInsuranceNode(state: GraphStateType, config: any) { + const lang = state.language || "English"; + const apiKey: string | undefined = config?.configurable?.apiKey; + + const fallbacks: Record = { + English: "Do you still have the same insurance?", + Spanish: "¿Sigue teniendo el mismo seguro?", + Portuguese: "Você ainda tem o mesmo plano?", + Mandarin: "您还有相同的保险吗?", + Cantonese: "您仍然有相同的保險嗎?", + Arabic: "هل لا تزال تمتلك نفس التأمين؟", + "Haitian Creole": "Èske ou toujou gen menm asirans lan?", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. Ask the existing patient in ${lang} if they still have the same dental insurance on file. One sentence, no formatting.`, + `Existing patient confirmed. Ask about insurance.`, + fallback, apiKey + ) + : fallback; + + return { reply, nextStage: "asked_existing_insurance" }; +} + +async function askMassHealthInfoNode(state: GraphStateType, config: any) { + const lang = state.language || "English"; + const apiKey: string | undefined = config?.configurable?.apiKey; + + const fallbacks: Record = { + English: "I can check your MassHealth coverage! Please text me your Member ID and date of birth.", + Spanish: "¡Puedo verificar su cobertura de MassHealth! Por favor envíeme su número de miembro y fecha de nacimiento.", + Portuguese: "Posso verificar sua cobertura MassHealth! Por favor envie seu número de membro e data de nascimento.", + Mandarin: "我可以查看您的MassHealth保险!请发送您的会员ID和出生日期。", + Cantonese: "我可以查核您的MassHealth保險!請傳送您的會員ID和出生日期。", + Arabic: "يمكنني التحقق من تغطية MassHealth الخاصة بك! من فضلك أرسل لي رقم العضوية وتاريخ الميلاد.", + "Haitian Creole": "Mwen ka verifye asirans MassHealth ou! Tanpri voye ID manm ou ak dat nesans ou.", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. The patient has MassHealth. Tell them in ${lang} that you can check their coverage and ask them to send their Member ID and date of birth. 1-2 sentences, no formatting.`, + `Patient has MassHealth. Ask for member ID and DOB.`, + fallback, apiKey + ) + : fallback; + + return { reply, nextStage: "awaiting_masshealth_info" }; +} + +async function askAppointmentTimeNode(state: GraphStateType, config: any) { + const lang = state.language || "English"; + const apiKey: string | undefined = config?.configurable?.apiKey; + + const fallbacks: Record = { + English: "When would you like to make an appointment?", + Spanish: "¿Cuándo le gustaría hacer una cita?", + Portuguese: "Quando você gostaria de agendar uma consulta?", + Mandarin: "您想什么时候预约?", + Cantonese: "您想幾時預約?", + Arabic: "متى تريد تحديد موعد؟", + "Haitian Creole": "Ki lè ou ta renmen fè yon randevou?", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. Ask the patient in ${lang} when they would like to schedule their appointment. One sentence, no formatting.`, + `Ask when to schedule.`, + fallback, apiKey + ) + : fallback; + + return { reply, nextStage: "asked_appointment_time" }; +} + +async function acknowledgeAppointmentTimeNode(state: GraphStateType, config: any) { + const lang = state.language || "English"; + const apiKey: string | undefined = config?.configurable?.apiKey; + + const fallbacks: Record = { + English: "Thank you! Our office staff will confirm your appointment details shortly.", + Spanish: "¡Gracias! El personal de la oficina confirmará los detalles de su cita en breve.", + Portuguese: "Obrigado! Nossa equipe confirmará os detalhes da sua consulta em breve.", + Mandarin: "谢谢!我们的工作人员将很快确认您的预约详情。", + Cantonese: "多謝!我們的工作人員將很快確認您的預約詳情。", + Arabic: "شكراً! سيؤكد فريق مكتبنا تفاصيل موعدك قريباً.", + "Haitian Creole": "Mèsi! Ekip biwo nou an pral konfime detay randevou ou a byento.", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. The patient stated their preferred appointment time. Acknowledge in ${lang} and tell them the office staff will confirm shortly. 1-2 sentences, no formatting.`, + `Patient said: "${state.message}". Acknowledge.`, + fallback, apiKey + ) + : fallback; + + return { reply, nextStage: "done" }; +} + +// ── Post-Selenium: appointment preference (MassHealth ACTIVE) ───────────────── + +async function handleAppointmentPreferenceNode(state: GraphStateType, config: any) { + const lang = state.language || "English"; + const apiKey: string | undefined = config?.configurable?.apiKey; + + const fallbacks: Record = { + English: "When would you like to come in? Are you looking for a routine check-up and teeth cleaning, or do you have a tooth problem or pain?", + Spanish: "¿Cuándo le gustaría venir? ¿Busca una revisión rutinaria y limpieza dental, o tiene algún problema dental o dolor?", + Portuguese: "Quando gostaria de vir? Você busca uma consulta de rotina e limpeza, ou tem algum problema dentário ou dor?", + Mandarin: "您想什么时候来?您是想做常规检查和洗牙,还是您有牙齿问题或疼痛?", + Cantonese: "您想幾時來?您是想做例行檢查和洗牙,還是您有牙齒問題或疼痛?", + Arabic: "متى تودّ الحضور؟ هل تبحث عن فحص روتيني وتنظيف أسنان، أم أن لديك مشكلة في الأسنان أو ألماً؟", + "Haitian Creole": "Ki lè ou ta renmen vini? Èske ou ap chèche yon egzamen woutin ak netwayaj dan, oswa èske ou gen yon pwoblèm dan oswa doulè?", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. The patient's MassHealth is active. In ${lang}, ask when they would like to come in and whether they want a routine check-up and teeth cleaning, or if they have a dental problem or pain. 1-2 sentences, no formatting.`, + `MassHealth is active. Ask appointment preference.`, + fallback, apiKey + ) + : fallback; + + return { reply, nextStage: "asked_appointment_time" }; +} + +// ── Post-Selenium: self-pay offer (MassHealth INACTIVE) ─────────────────────── + +async function handleSelfPayNode(state: GraphStateType, config: any) { + const lang = state.language || "English"; + const apiKey: string | undefined = config?.configurable?.apiKey; + const text = state.message.toLowerCase(); + + // Classify yes/no for self-pay + const acceptsSelfPay = /yes|sure|ok|okay|yep|yeah|sí|si|claro|sim|confirmado|好的|نعم|wi|oke/i.test(text); + const declinesSelfPay = /no|nope|can't|won't|not interested|no puedo|não|لا|pa ka/i.test(text); + + if (declinesSelfPay) { + const declineFallbacks: Record = { + English: "No problem! If you ever change your mind or have any questions, feel free to reach out. Our staff is happy to help.", + Spanish: "¡Sin problema! Si cambia de opinión o tiene alguna pregunta, no dude en contactarnos.", + Portuguese: "Sem problema! Se mudar de ideia ou tiver dúvidas, entre em contato. Nossa equipe está feliz em ajudar.", + Mandarin: "没关系!如果您改变主意或有任何问题,随时联系我们,我们的工作人员很乐意帮助您。", + Cantonese: "沒問題!如果您改變主意或有任何問題,隨時聯繫我們。", + Arabic: "لا بأس! إذا غيّرت رأيك أو كان لديك أي سؤال، لا تتردد في التواصل معنا.", + "Haitian Creole": "Pa gen pwoblèm! Si ou chanje lide ou oswa gen kesyon, pa ezite kontakte nou.", + }; + const fallback = declineFallbacks[lang] ?? declineFallbacks["English"]!; + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. The patient declined a self-pay appointment. In ${lang}, politely close the conversation and invite them to reach out anytime. 1-2 sentences, no formatting.`, + `Patient declined self-pay.`, + fallback, apiKey + ) + : fallback; + return { reply, nextStage: "done" }; + } + + if (acceptsSelfPay) { + const acceptFallbacks: Record = { + English: "When would you like to come in for your examination?", + Spanish: "¿Cuándo le gustaría venir para su examen?", + Portuguese: "Quando você gostaria de vir para seu exame?", + Mandarin: "您想什么时候来进行检查?", + Cantonese: "您想幾時來進行檢查?", + Arabic: "متى تودّ الحضور للفحص؟", + "Haitian Creole": "Ki lè ou ta renmen vini pou egzamen ou?", + }; + const fallback = acceptFallbacks[lang] ?? acceptFallbacks["English"]!; + const reply = apiKey + ? await llmReply( + `You are a friendly dental office AI assistant. The patient agreed to a self-pay examination. In ${lang}, ask when they would like to come in. One sentence, no formatting.`, + `Patient agreed to self-pay. Ask when to come in.`, + fallback, apiKey + ) + : fallback; + return { reply, nextStage: "asked_appointment_time" }; + } + + // Ambiguous — transfer to staff + return { reply: transferMsg(lang), nextStage: "done" }; +} + +function transferNode(state: GraphStateType) { + const lang = state.language || "English"; + return { reply: transferMsg(lang), nextStage: "done" }; +} + +// ── Graph assembly ──────────────────────────────────────────────────────────── + +const graph = new StateGraph(GraphState) + .addNode("classify", classifyNode) + .addNode("ask_new_or_existing", askNewOrExistingNode) + .addNode("ask_new_patient_insurance", askNewPatientInsuranceNode) + .addNode("ask_existing_insurance", askExistingInsuranceNode) + .addNode("ask_masshealth_info", askMassHealthInfoNode) + .addNode("ask_appointment_time", askAppointmentTimeNode) + .addNode("acknowledge_appointment_time", acknowledgeAppointmentTimeNode) + .addNode("handle_appointment_preference",handleAppointmentPreferenceNode) + .addNode("handle_self_pay", handleSelfPayNode) + .addNode("transfer", transferNode) + .addEdge(START, "classify") + .addConditionalEdges("classify", routeNode, { + ask_new_or_existing: "ask_new_or_existing", + ask_new_patient_insurance: "ask_new_patient_insurance", + ask_existing_insurance: "ask_existing_insurance", + ask_masshealth_info: "ask_masshealth_info", + ask_appointment_time: "ask_appointment_time", + acknowledge_appointment_time: "acknowledge_appointment_time", + handle_appointment_preference: "handle_appointment_preference", + handle_self_pay: "handle_self_pay", + transfer: "transfer", + }) + .addEdge("ask_new_or_existing", END) + .addEdge("ask_new_patient_insurance", END) + .addEdge("ask_existing_insurance", END) + .addEdge("ask_masshealth_info", END) + .addEdge("ask_appointment_time", END) + .addEdge("acknowledge_appointment_time", END) + .addEdge("handle_appointment_preference", END) + .addEdge("handle_self_pay", END) + .addEdge("transfer", END) + .compile(); + +// ── Public API ──────────────────────────────────────────────────────────────── + +export async function runNewPatientStep( + message: string, + stage: ConversationStage, + language: string, + apiKey: string +): Promise<{ reply: string; nextStage: ConversationStage }> { + const result = await graph.invoke( + { message, stage, intent: "", reply: "", language, nextStage: "" }, + { configurable: { apiKey } } + ); + return { + reply: result.reply || transferMsg(language), + nextStage: (result.nextStage as ConversationStage) || "done", + }; +} diff --git a/apps/Backend/src/ai/reminder-graph.ts b/apps/Backend/src/ai/reminder-graph.ts index a4ce4951..ae6a6f90 100644 --- a/apps/Backend/src/ai/reminder-graph.ts +++ b/apps/Backend/src/ai/reminder-graph.ts @@ -2,44 +2,80 @@ import { StateGraph, END, START, Annotation } from "@langchain/langgraph"; import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; const GraphState = Annotation.Root({ - message: Annotation(), - intent: Annotation(), - reply: Annotation(), + message: Annotation(), + intent: Annotation(), + reply: Annotation(), + language: Annotation(), + appointmentDatetime: Annotation(), }); 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 = { + 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 = { + 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, }; } diff --git a/apps/Backend/src/ai/reschedule-graph.ts b/apps/Backend/src/ai/reschedule-graph.ts new file mode 100644 index 00000000..d43a1ef5 --- /dev/null +++ b/apps/Backend/src/ai/reschedule-graph.ts @@ -0,0 +1,470 @@ +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import { prisma as db } from "@repo/db/client"; +import { storage } from "../storage"; +import { + type ConversationStage, + setPendingReschedule, + getPendingReschedule, + clearPendingReschedule, +} from "./aiHandoffStore"; + +// ── Date helpers ────────────────────────────────────────────────────────────── + +const MONTHS = [ + "January","February","March","April","May","June", + "July","August","September","October","November","December", +]; +const DAYS = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]; + +function formattedDate(d: Date): string { + return `${DAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`; +} + +/** Get the day-of-week (0=Sun…6=Sat) of the patient's next scheduled appointment. */ +async function getAppointmentDow(patientId: number): Promise { + const today = new Date(); today.setHours(0, 0, 0, 0); + const appt = await db.appointment.findFirst({ + where: { patientId, status: "scheduled", date: { gte: today } }, + orderBy: { date: "asc" }, + select: { date: true }, + }); + if (!appt) return -1; + return new Date(appt.date).getUTCDay(); +} + +function getTomorrow(): string { + const t = new Date(); + t.setDate(t.getDate() + 1); + return formattedDate(t); +} + +function getNextWeekDays(): { mon: string; tue: string; wed: string } { + const today = new Date(); + const dow = today.getDay(); + // Days until next Monday (always at least 1 day away, never 0) + const daysToMon = (8 - dow) % 7 || 7; + const mon = new Date(today); mon.setDate(today.getDate() + daysToMon); + const tue = new Date(mon); tue.setDate(mon.getDate() + 1); + const wed = new Date(mon); wed.setDate(mon.getDate() + 2); + return { mon: formattedDate(mon), tue: formattedDate(tue), wed: formattedDate(wed) }; +} + +// ── Date objects for rescheduling ───────────────────────────────────────────── + +function getTomorrowDate(): { date: Date; label: string } { + const d = new Date(); + d.setDate(d.getDate() + 1); + d.setUTCHours(0, 0, 0, 0); + return { date: d, label: formattedDate(new Date(d.getTime() + d.getTimezoneOffset() * 60000)) }; +} + +function getNextWeekDateObjects(): { mon: { date: Date; label: string }; tue: { date: Date; label: string }; wed: { date: Date; label: string } } { + const today = new Date(); + const dow = today.getDay(); + const daysToMon = (8 - dow) % 7 || 7; + + const mkDate = (offset: number) => { + const d = new Date(today); + d.setDate(today.getDate() + offset); + d.setUTCHours(0, 0, 0, 0); + const label = formattedDate(new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset)); + return { date: d, label }; + }; + return { mon: mkDate(daysToMon), tue: mkDate(daysToMon + 1), wed: mkDate(daysToMon + 2) }; +} + +// ── Time parsing ────────────────────────────────────────────────────────────── + +/** Parse patient's time preference into 24-h "HH:MM" string or null. */ +async function parseTime(message: string, apiKey: string): Promise { + const t = message.toLowerCase(); + + // Keyword shortcuts + if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00"; + if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00"; + + // Numeric patterns: "10am", "10:30", "2pm", "14:00" + const ampm = t.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/); + const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/); + + if (ampm) { + let h = parseInt(ampm[1]!); + const m = ampm[2] ? parseInt(ampm[2]) : 0; + if (ampm[3] === "pm" && h < 12) h += 12; + if (ampm[3] === "am" && h === 12) h = 0; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; + } + if (clock) return clock[0]!; + + // LLM fallback + try { + const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); + const res = await llm.invoke([ + { role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format (e.g., "10:00", "14:30"). If no time is mentioned, return "null".' }, + { role: "user", content: message }, + ]); + const raw = String(res.content).trim(); + if (/^([01]?\d|2[0-3]):[0-5]\d$/.test(raw)) return raw; + } catch { /* fall through */ } + + return null; +} + +// ── Appointment update ──────────────────────────────────────────────────────── + +async function moveAppointment( + patientId: number, + newDate: Date, + newStartTime: string, // "HH:MM" +): Promise { + const today = new Date(); today.setHours(0, 0, 0, 0); + const appt = await db.appointment.findFirst({ + where: { patientId, status: "scheduled", date: { gte: today } }, + orderBy: { date: "asc" }, + }); + if (!appt) return "no_appointment"; + + // Preserve original duration + const [sh, sm] = appt.startTime.split(":").map(Number); + const [eh, em] = appt.endTime.split(":").map(Number); + const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!); + const [nh, nm] = newStartTime.split(":").map(Number); + const endTotalMin = nh! * 60 + nm! + durationMin; + const newEndTime = `${String(Math.floor(endTotalMin / 60)).padStart(2, "0")}:${String(endTotalMin % 60).padStart(2, "0")}`; + + await storage.updateAppointment(appt.id, { + date: newDate, + startTime: newStartTime, + endTime: newEndTime, + status: "scheduled", + } as any); + + return "ok"; +} + +// ── Intent classifiers ──────────────────────────────────────────────────────── + +function yes(t: string) { + return /\b(yes|yeah|yep|sure|ok|okay|please|absolutely|definitely|sí|si|sim|好的|نعم|wi|oke)\b/i.test(t); +} +function no(t: string) { + return /\b(no|nope|not|don't|won't|can't|لا|pa ka|não)\b/i.test(t); +} +function wantsAsap(t: string) { + return /\b(asap|soon|soonest|next day|tomorrow|quick|fastest|earliest|lo antes|mañana|amanhã|明天|明日|غدًا|demen)\b/i.test(t); +} +function wantsNextWeek(t: string) { + return /\b(next week|semana|prochain|prochaine|下周|下週|الأسبوع القادم|semèn pwochèn)\b/i.test(t); +} +function prefersMonday(t: string) { return /monday|lunes|segunda|周一|月曜|الاثنين|lendi/i.test(t); } +function prefersTuesday(t: string) { return /tuesday|martes|terça|周二|火曜|الثلاثاء|madi/i.test(t); } +function prefersWednesday(t: string) { return /wednesday|miércoles|quarta|周三|水曜|الأربعاء|mèkredi/i.test(t); } + +// ── LLM helper ──────────────────────────────────────────────────────────────── + +async function llmReply(system: string, user: string, fallback: string, apiKey: string): Promise { + try { + const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); + const res = await llm.invoke([ + { role: "system", content: system }, + { role: "user", content: user }, + ]); + return String(res.content).trim() || fallback; + } catch { + return fallback; + } +} + +// ── Transfer fallback ───────────────────────────────────────────────────────── + +const TRANSFER: Record = { + English: "Our office staff will contact you shortly to help with rescheduling.", + Spanish: "El personal de nuestra oficina le contactará pronto para ayudarle a reprogramar.", + Portuguese: "Nossa equipe entrará em contato em breve para ajudá-lo a reagendar.", + Mandarin: "我们的工作人员将很快与您联系以帮助重新安排预约。", + Cantonese: "我們的工作人員將很快聯絡您以協助重新安排預約。", + Arabic: "سيتصل بك موظفو مكتبنا قريباً لمساعدتك في إعادة الجدولة.", + "Haitian Creole": "Anplwaye biwo nou yo pral kontakte ou byento pou ede ou repwograme.", +}; + +// ── Main step function ──────────────────────────────────────────────────────── + +export async function runRescheduleStep( + message: string, + stage: ConversationStage, + language: string, + patientId: number, + apiKey: string, + userId: number = 0, +): Promise<{ reply: string; nextStage: ConversationStage }> { + + const lang = language || "English"; + const t = message.toLowerCase(); + const tx = TRANSFER[lang] ?? TRANSFER["English"]!; + + // ── asked_reschedule_confirm: patient answered "Would you like to reschedule?" ── + if (stage === "asked_reschedule_confirm") { + if (no(t)) { + const fallbacks: Record = { + English: "No problem! Feel free to reach out whenever you're ready. Have a great day!", + Spanish: "¡Sin problema! No dude en contactarnos cuando esté listo. ¡Que tenga un buen día!", + Portuguese: "Sem problema! Entre em contato quando estiver pronto. Tenha um ótimo dia!", + Mandarin: "没关系!随时联系我们。祝您有美好的一天!", + Cantonese: "沒問題!隨時聯繫我們。祝您有美好的一天!", + Arabic: "لا بأس! لا تتردد في التواصل معنا متى كنت مستعداً. أتمنى لك يوماً رائعاً!", + "Haitian Creole": "Pa gen pwoblèm! Kontakte nou nenpòt ki lè ou prèt. Pase yon bèl jounen!", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. The patient does not want to reschedule. Write a warm, brief closing message in ${lang}. 1 sentence, no formatting.`, + `Patient said: "${message}"`, fallback, apiKey + ); + return { reply, nextStage: "done" }; + } + + if (yes(t)) { + // Check if original appointment was Mon–Thu (days 1–4) + const dow = await getAppointmentDow(patientId); + const isMonToThu = dow >= 1 && dow <= 4; + + if (isMonToThu) { + // Offer ASAP or next week + const fallbacks: Record = { + English: "Would you like to reschedule as soon as possible, or would you prefer next week?", + Spanish: "¿Le gustaría reprogramar lo antes posible, o prefiere la semana que viene?", + Portuguese: "Gostaria de reagendar o mais rápido possível, ou prefere a semana que vem?", + Mandarin: "您想尽快重新安排预约,还是下周更方便?", + Cantonese: "您想盡快重新安排預約,還是下週更方便?", + Arabic: "هل تفضل إعادة الجدولة في أقرب وقت ممكن، أم تفضل الأسبوع القادم؟", + "Haitian Creole": "Èske ou ta renmen repwograme pi vit posib, oswa ou prefere semèn pwochèn?", + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. The patient wants to reschedule. Their original appointment was on a weekday. Ask in ${lang} whether they prefer to reschedule as soon as possible or next week. 1 sentence, no formatting.`, + `Patient said: "${message}"`, fallback, apiKey + ); + return { reply, nextStage: "asked_reschedule_preference" }; + } else { + // Original appointment was Fri/Sat/Sun — go straight to next week + const { mon, tue, wed } = getNextWeekDays(); + const fallbacks: Record = { + English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`, + Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`, + Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom para você?`, + Mandarin: `我可以安排您在下周预约。${mon}、${tue} 或 ${wed} 方便吗?`, + Cantonese: `我可以安排您在下週預約。${mon}、${tue} 或 ${wed} 方便嗎?`, + Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`, + "Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay pou ou?`, + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. Offer the patient next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`, + `Patient wants to reschedule.`, fallback, apiKey + ); + return { reply, nextStage: "asked_reschedule_next_week" }; + } + } + + return { reply: tx, nextStage: "done" }; + } + + // ── asked_reschedule_preference: patient chose ASAP or next week ────────── + if (stage === "asked_reschedule_preference") { + if (wantsAsap(t) || yes(t)) { + const tomorrow = getTomorrow(); + const fallbacks: Record = { + English: `Can you come in tomorrow, ${tomorrow}?`, + Spanish: `¿Podría venir mañana, ${tomorrow}?`, + Portuguese: `Você pode vir amanhã, ${tomorrow}?`, + Mandarin: `您明天(${tomorrow})能来吗?`, + Cantonese: `您明天(${tomorrow})能來嗎?`, + Arabic: `هل يمكنك الحضور غداً، ${tomorrow}؟`, + "Haitian Creole": `Èske ou ka vini demen, ${tomorrow}?`, + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. Ask the patient in ${lang} if they can come in tomorrow, ${tomorrow}. 1 sentence, no formatting.`, + `Patient wants to reschedule ASAP.`, fallback, apiKey + ); + return { reply, nextStage: "asked_reschedule_asap" }; + } + + if (wantsNextWeek(t)) { + const { mon, tue, wed } = getNextWeekDays(); + const fallbacks: Record = { + English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`, + Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`, + Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom para você?`, + Mandarin: `我可以安排您在下周预约。${mon}、${tue} 或 ${wed} 方便吗?`, + Cantonese: `我可以安排您在下週預約。${mon}、${tue} 或 ${wed} 方便嗎?`, + Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`, + "Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay pou ou?`, + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`, + `Patient prefers next week.`, fallback, apiKey + ); + return { reply, nextStage: "asked_reschedule_next_week" }; + } + + return { reply: tx, nextStage: "done" }; + } + + // ── asked_reschedule_asap: patient answered "Can you come tomorrow?" ─────── + if (stage === "asked_reschedule_asap") { + if (yes(t)) { + const { date, label } = getTomorrowDate(); + setPendingReschedule(userId, patientId, { newDate: date, dayLabel: label }); + + const fallbacks: Record = { + English: `${label} it is! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`, + Spanish: `¡${label} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`, + Portuguese: `${label} ótimo! Você prefere manhã (9h–12h) ou tarde (13h–17h)?`, + Mandarin: `${label},太好了!您想预约上午(9点–12点)还是下午(1点–5点)?`, + Cantonese: `${label},太好了!您想預約上午(9點–12點)還是下午(1點–5點)?`, + Arabic: `${label} ممتاز! هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟`, + "Haitian Creole": `${label} pafè! Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?`, + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. The patient confirmed ${label}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`, + `Patient confirmed tomorrow.`, fallback, apiKey + ); + return { reply, nextStage: "asked_reschedule_time" }; + } + + if (no(t)) { + // Can't make tomorrow — offer next week instead + const { mon, tue, wed } = getNextWeekDays(); + const fallbacks: Record = { + English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work for you?`, + Spanish: `¡Sin problema! ¿Qué le parece la semana que viene? ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`, + Portuguese: `Sem problema! E na semana que vem? ${mon}, ${tue} ou ${wed} seria bom?`, + Mandarin: `没关系!下周怎么样?${mon}、${tue} 或 ${wed} 方便吗?`, + Cantonese: `沒問題!下週怎麼樣?${mon}、${tue} 或 ${wed} 方便嗎?`, + Arabic: `لا بأس! ماذا عن الأسبوع القادم؟ هل ${mon} أو ${tue} أو ${wed} يناسبك؟`, + "Haitian Creole": `Pa gen pwoblèm! Ki sa ki dire semèn pwochèn? ${mon}, ${tue}, oswa ${wed} ka travay?`, + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`, + `Patient can't come tomorrow.`, fallback, apiKey + ); + return { reply, nextStage: "asked_reschedule_next_week" }; + } + + return { reply: tx, nextStage: "done" }; + } + + // ── asked_reschedule_next_week: patient choosing Mon/Tue/Wed ───────────── + if (stage === "asked_reschedule_next_week") { + const nwd = getNextWeekDateObjects(); + let chosen: { date: Date; label: string } | null = null; + if (prefersMonday(t)) chosen = nwd.mon; + if (prefersTuesday(t)) chosen = nwd.tue; + if (prefersWednesday(t)) chosen = nwd.wed; + + if (chosen) { + setPendingReschedule(userId, patientId, { newDate: chosen.date, dayLabel: chosen.label }); + + const day = chosen.label; + const fallbacks: Record = { + English: `${day} works! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`, + Spanish: `¡${day} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`, + Portuguese: `${day} ótimo! Você prefere manhã (9h–12h) ou tarde (13h–17h)?`, + Mandarin: `${day},好的!您想预约上午(9点–12点)还是下午(1点–5点)?`, + Cantonese: `${day},好的!您想預約上午(9點–12點)還是下午(1點–5點)?`, + Arabic: `${day} رائع! هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟`, + "Haitian Creole": `${day} pafè! Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?`, + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. The patient chose ${day}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`, + `Patient chose ${day}.`, fallback, apiKey + ); + return { reply, nextStage: "asked_reschedule_time" }; + } + + // Day not clearly detected — ask again with the specific options + const fallbacks: Record = { + English: `Which day works best — ${mon}, ${tue}, or ${wed}?`, + Spanish: `¿Qué día le viene mejor — el ${mon}, ${tue} o el ${wed}?`, + Portuguese: `Qual dia é melhor — ${mon}, ${tue} ou ${wed}?`, + Mandarin: `哪天最方便——${mon}、${tue} 还是 ${wed}?`, + Cantonese: `哪天最方便——${mon}、${tue} 還是 ${wed}?`, + Arabic: `أي يوم هو الأفضل لك — ${mon} أو ${tue} أو ${wed}؟`, + "Haitian Creole": `Ki jou ki pi bon — ${mon}, ${tue}, oswa ${wed}?`, + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + return { reply: fallback, nextStage: "asked_reschedule_next_week" }; + } + + // ── asked_reschedule_time: patient picked morning / afternoon / specific time ── + if (stage === "asked_reschedule_time") { + const pending = getPendingReschedule(userId, patientId); + + if (!pending) { + // Edge case: lost state — fall back gracefully + return { reply: tx, nextStage: "done" }; + } + + const startTime = await parseTime(message, apiKey); + + if (!startTime) { + // Couldn't parse time — ask again + const fallbacks: Record = { + English: "I didn't catch the time. Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?", + Spanish: "No entendí la hora. ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?", + Portuguese: "Não entendi o horário. Você prefere manhã (9h–12h) ou tarde (13h–17h)?", + Mandarin: "我没听清时间。您想预约上午(9点–12点)还是下午(1点–5点)?", + Cantonese: "我沒聽清時間。您想預約上午(9點–12點)還是下午(1點–5點)?", + Arabic: "لم أفهم الوقت. هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟", + "Haitian Creole": "Mwen pa konprann lè a. Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?", + }; + return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time" }; + } + + // Update the appointment in the database + const updateResult = await moveAppointment(patientId, pending.newDate, startTime); + clearPendingReschedule(userId, patientId); + + const [h, m] = startTime.split(":").map(Number); + const h12 = h! % 12 || 12; + const ampm = h! >= 12 ? "pm" : "am"; + const timeLabel = `${h12}:${String(m!).padStart(2, "0")} ${ampm}`; + const apptLabel = `${pending.dayLabel} at ${timeLabel}`; + + if (updateResult === "no_appointment") { + const fallbacks: Record = { + English: `I couldn't find your appointment to update. Our staff will contact you to confirm ${apptLabel}.`, + Spanish: `No encontré su cita para actualizar. El personal le contactará para confirmar el ${apptLabel}.`, + Portuguese: `Não encontrei sua consulta para atualizar. Nossa equipe entrará em contato para confirmar ${apptLabel}.`, + Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${apptLabel}。`, + Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${apptLabel}。`, + Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${apptLabel}.`, + "Haitian Creole": `Mwen pa jwenn randevou ou pou mete ajou. Anplwaye nou yo pral kontakte ou pou konfime ${apptLabel}.`, + }; + return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" }; + } + + // Success + const fallbacks: Record = { + English: `Your appointment has been moved to ${apptLabel}. See you then!`, + Spanish: `Su cita ha sido cambiada al ${apptLabel}. ¡Hasta entonces!`, + Portuguese: `Sua consulta foi remarcada para ${apptLabel}. Até lá!`, + Mandarin: `您的预约已更改为${apptLabel}。到时见!`, + Cantonese: `您的預約已更改為${apptLabel}。到時見!`, + Arabic: `تم تغيير موعدك إلى ${apptLabel}. نراك قريباً!`, + "Haitian Creole": `Randevou ou a deplase ale nan ${apptLabel}. N'ap wè ou lè sa a!`, + }; + const fallback = fallbacks[lang] ?? fallbacks["English"]!; + const reply = await llmReply( + `You are a friendly dental office assistant. The patient's appointment has been successfully rescheduled to ${apptLabel}. Confirm in ${lang} with enthusiasm. 1 sentence, no formatting.`, + `Appointment moved to ${apptLabel}.`, fallback, apiKey + ); + return { reply, nextStage: "done" }; + } + + return { reply: tx, nextStage: "done" }; +} diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 9e1a4c2b..0792109d 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -36,4 +36,30 @@ router.put("/settings", async (req: Request, res: Response): Promise => { } }); +// GET /api/ai/chat-templates +router.get("/chat-templates", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const templates = await storage.getAiChatTemplates(userId); + return res.status(200).json(templates); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch AI chat templates", details: String(err) }); + } +}); + +// PUT /api/ai/chat-templates +router.put("/chat-templates", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { reminderGreeting, newPatientGreeting, generalFallback } = req.body; + await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback }); + const updated = await storage.getAiChatTemplates(userId); + return res.status(200).json(updated); + } catch (err) { + return res.status(500).json({ error: "Failed to save AI chat templates", details: String(err) }); + } +}); + export default router; diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index 4ea87c0f..26b78dc0 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -1,64 +1,442 @@ import express, { Request, Response } from "express"; +import twilio from "twilio"; import { storage } from "../storage"; import { prisma as db } from "@repo/db/client"; import { runReminderGraph } from "../ai/reminder-graph"; +import { runNewPatientStep } from "../ai/new-patient-graph"; +import { runRescheduleStep } from "../ai/reschedule-graph"; +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor"; +import { + getHandoff, getAfterHoursHandoff, + getStage, setStage, + type ConversationStage, +} from "../ai/aiHandoffStore"; const router = express.Router(); -// POST /api/twilio/webhook/sms (Twilio posts inbound SMS here — no auth) +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +function twimlReply(text: string): string { + return `${escapeXml(text)}`; +} + +function empty(): string { + return ""; +} + +/** Get the patient's next scheduled appointment as a human-readable string. */ +async function getAppointmentDatetime(patientId: number): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const appt = await db.appointment.findFirst({ + where: { patientId, status: "scheduled", date: { gte: today } }, + orderBy: { date: "asc" }, + }); + if (!appt) return ""; + const months = ["January","February","March","April","May","June", + "July","August","September","October","November","December"]; + const d = new Date(appt.date); + return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} at ${appt.startTime}`; +} + +/** Check if right now is outside office hours for the given user. */ +async function isAfterHours(userId: number): Promise { + const record = await storage.getOfficeHours(userId); + if (!record?.data) return false; // no hours configured → treat as in-hours + + const data = record.data as any; + const now = new Date(); + const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"]; + const day = days[now.getDay()]; + + const slot = data.doctors?.[day]; + if (!slot?.enabled) return true; // office closed today + + const hhmm = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`; + if (hhmm >= slot.amStart && hhmm <= slot.amEnd) return false; + if (hhmm >= slot.pmStart && hhmm <= slot.pmEnd) return false; + return true; +} + +/** Substitute {officeName} in a template string. */ +function applyOfficeName(template: string, name: string): string { + return template.replace(/\{officeName\}/g, name || "our dental office"); +} + +/** Save an outbound message and return the text. */ +async function saveOutbound(patientId: number, body: string): Promise { + await storage.createCommunication({ + patientId, channel: "sms", direction: "outbound", status: "sent", body, + }); +} + +/** + * Extract MassHealth Member ID and date of birth from a free-text SMS. + * Tries regex first, falls back to LLM extraction. + */ +async function parseMassHealthInfo( + message: string, + apiKey: string +): Promise<{ memberId: string | null; dob: string | null }> { + // Regex: member IDs are typically 8-12 digits; DOB as MM/DD/YYYY or similar + const idMatch = message.match(/\b(\d{8,12})\b/); + const dobMatch = message.match(/\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{2,4})\b/); + + if (idMatch && dobMatch) { + const [, m, d, y] = dobMatch; + const year = y!.length === 2 ? `20${y}` : y; + return { memberId: idMatch[1]!, dob: `${m}/${d}/${year}` }; + } + + // Fall back to LLM structured extraction + try { + const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); + const res = await llm.invoke([ + { + role: "system", + content: + 'Extract the insurance member ID and date of birth from the patient message. ' + + 'Return ONLY valid JSON: {"memberId":"...","dob":"MM/DD/YYYY"}. Use null for missing fields.', + }, + { role: "user", content: message }, + ]); + const raw = String(res.content).replace(/```json|```/g, "").trim(); + const json = JSON.parse(raw); + return { memberId: json.memberId ?? null, dob: json.dob ?? null }; + } catch { + return { memberId: null, dob: null }; + } +} + +/** + * Run MassHealth eligibility check in the background (after replying to patient) + * and send the result as a follow-up SMS. + */ +async function runMassHealthCheckAndNotify( + patient: { id: number; userId: number; phone: string | null; preferredLanguage: string | null }, + memberId: string, + dob: string, + apiKey: string, + isExistingPatient = false +): Promise { + try { + const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(patient.userId, "MH"); + if (!credentials) return; + + const twilioSettings = await storage.getTwilioSettings(patient.userId); + if (!twilioSettings || !patient.phone) return; + + // Run Selenium eligibility check directly via the processor + await runEligibilityProcessor({ + userId: patient.userId, + insuranceId: memberId, + formDob: dob, + enrichedPayload: { + memberId, + dateOfBirth: dob, + insuranceSiteKey: "MH", + massdhpUsername: credentials.username, + massdhpPassword: credentials.password, + }, + }); + + // Re-fetch updated patient status + const updated = await db.patient.findUnique({ + where: { id: patient.id }, + select: { status: true, firstName: true }, + }); + + const lang = patient.preferredLanguage || "English"; + const active = updated?.status === "ACTIVE"; + + // ── ACTIVE: existing patient → simple scheduling; new patient → preference ─ + const activeMessagesExisting: Record = { + English: "Great news! Your MassHealth coverage is active. When would you like to come in for your appointment?", + Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¿Cuándo le gustaría venir para su cita?", + Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Quando gostaria de vir para sua consulta?", + Mandarin: "好消息!您的MassHealth保险有效。您想什么时候来预约?", + Cantonese: "好消息!您的MassHealth保險有效。您想幾時來預約?", + Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. متى تودّ الحضور لموعدك؟", + "Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Ki lè ou ta renmen vini pou randevou ou?", + }; + + const activeMessagesNew: Record = { + English: "Great news! Your MassHealth coverage is active. When would you like to come in? Are you looking for a routine check-up and teeth cleaning, or do you have a tooth problem or pain?", + Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¿Cuándo le gustaría venir? ¿Busca una revisión rutinaria y limpieza dental, o tiene algún problema dental o dolor?", + Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Quando gostaria de vir? Você busca uma consulta de rotina e limpeza, ou tem algum problema dentário ou dor?", + Mandarin: "好消息!您的MassHealth保险有效。您想什么时候来?您是想做常规检查和洗牙,还是您有牙齿问题或疼痛?", + Cantonese: "好消息!您的MassHealth保險有效。您想幾時來?您是想做例行檢查和洗牙,還是您有牙齒問題或疼痛?", + Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. متى تودّ الحضور؟ هل تبحث عن فحص روتيني وتنظيف أسنان، أم أن لديك مشكلة في الأسنان أو ألماً؟", + "Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Ki lè ou ta renmen vini? Èske ou ap chèche yon egzamen woutin ak netwayaj dan, oswa ou gen pwoblèm dan oswa doulè?", + }; + + const activeMessages = isExistingPatient ? activeMessagesExisting : activeMessagesNew; + + // ── INACTIVE: offer self-pay examination ──────────────────────────── + const inactiveMessages: Record = { + English: "We checked your MassHealth coverage. Unfortunately the plan appears inactive or could not be verified. Would you still like to schedule an examination appointment as a self-pay patient?", + Spanish: "Verificamos su cobertura de MassHealth. Lamentablemente el plan aparece inactivo o no pudo ser verificado. ¿Le gustaría programar una cita de examen como paciente de pago particular?", + Portuguese: "Verificamos sua cobertura MassHealth. Infelizmente o plano parece inativo ou não pôde ser verificado. Gostaria de agendar uma consulta de exame como paciente particular?", + Mandarin: "我们查看了您的MassHealth保险。遗憾的是,保险似乎无效或无法验证。您仍然希望以自费方式预约检查吗?", + Cantonese: "我們查看了您的MassHealth保險。遺憾地,保險似乎無效或無法核實。您仍然希望以自費方式預約檢查嗎?", + Arabic: "تحققنا من تغطيتك من MassHealth. للأسف يبدو أن الخطة غير نشطة أو لا يمكن التحقق منها. هل تودّ تحديد موعد فحص كمريض يدفع من حسابه الخاص؟", + "Haitian Creole": "Nou te verifye kouvèti MassHealth ou. Malerezman plan an sanble inaktif oswa pa ka verifye. Èske ou ta renmen pran yon randevou egzamen kòm pasyan ki peye poukont li?", + }; + + const resultText = active + ? (activeMessages[lang] ?? activeMessages["English"]!) + : (inactiveMessages[lang] ?? inactiveMessages["English"]!); + + const nextStage: ConversationStage = active + ? (isExistingPatient ? "asked_appointment_time" : "asked_appointment_preference") + : "asked_self_pay"; + + // Send follow-up question via Twilio + const client = twilio(twilioSettings.accountSid, twilioSettings.authToken); + await client.messages.create({ + body: resultText, + from: twilioSettings.phoneNumber, + to: patient.phone, + }); + + // Persist and advance stage + await saveOutbound(patient.id, resultText); + setStage(patient.userId, patient.id, nextStage); + + } catch { + // Silent — don't crash the main request + } +} + +// ── POST /api/twilio/webhook/sms ────────────────────────────────────────────── + router.post("/webhook/sms", async (req: Request, res: Response): Promise => { try { const { From, Body, MessageSid } = req.body; const normalizedFrom = (From || "").replace(/\D/g, ""); - const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true } }); + const allPatients = await db.patient.findMany({ + select: { id: true, phone: true, userId: true, preferredLanguage: true }, + }); const patient = allPatients.find( - (p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom + (p) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom ); - if (patient) { - // Save the inbound message - await storage.createCommunication({ - patientId: patient.id, - channel: "sms", - direction: "inbound", - status: "delivered", - body: Body, - twilioSid: MessageSid, - }); + if (!patient) { + res.set("Content-Type", "text/xml"); + return res.send(empty()); + } - // Run AI graph if API key is configured - const aiSettings = await storage.getAiSettings(patient.userId); - if (aiSettings?.apiKey) { - const { reply, intent } = await runReminderGraph(Body, aiSettings.apiKey); + // Save inbound message + await storage.createCommunication({ + patientId: patient.id, channel: "sms", direction: "inbound", + status: "delivered", body: Body, twilioSid: MessageSid, + }); - if (reply) { - // Save the AI outbound reply - await storage.createCommunication({ - patientId: patient.id, - channel: "sms", - direction: "outbound", - status: "sent", - body: reply, - }); + // Per-patient handoff toggle must be ON + if (!getHandoff(patient.userId, patient.id)) { + res.set("Content-Type", "text/xml"); + return res.send(empty()); + } + const aiSettings = await storage.getAiSettings(patient.userId); + if (!aiSettings?.apiKey) { + res.set("Content-Type", "text/xml"); + return res.send(empty()); + } + + const language = patient.preferredLanguage || "English"; + const stage = getStage(patient.userId, patient.id); + + // ── Helper: send reply + set stage ───────────────────────────────────── + const reply = async (text: string, nextStage: ConversationStage) => { + await saveOutbound(patient.id, text); + setStage(patient.userId, patient.id, nextStage); + res.set("Content-Type", "text/xml"); + return res.send(twimlReply(text)); + }; + + // ── Stage: reminder_initial → send reminder greeting ───────────────── + if (stage === "reminder_initial") { + const chatTemplates = await storage.getAiChatTemplates(patient.userId); + const officeContact = await storage.getOfficeContact(patient.userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + + const rawGreeting = chatTemplates.reminderGreeting || + `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply your message at any time you need.`; + + return reply(applyOfficeName(rawGreeting, officeName), "greeted"); + } + + // ── Stage: greeted → classify yes/no for appointment reminder ──────── + if (stage === "greeted") { + const apptDatetime = await getAppointmentDatetime(patient.id); + const { reply: aiReply, intent } = await runReminderGraph(Body, aiSettings.apiKey, language, apptDatetime); + if (aiReply) { + // YES → done; NO → start rescheduling flow + const nextStage: ConversationStage = intent === "no" ? "asked_reschedule_confirm" : "done"; + return reply(aiReply, nextStage); + } + } + + // ── Rescheduling flow stages ─────────────────────────────────────────── + const rescheduleStages: ConversationStage[] = [ + "asked_reschedule_confirm", "asked_reschedule_preference", + "asked_reschedule_asap", "asked_reschedule_next_week", + "asked_reschedule_time", + ]; + if (rescheduleStages.includes(stage)) { + const { reply: aiReply, nextStage } = await runRescheduleStep( + Body, stage, language, patient.id, aiSettings.apiKey, patient.userId + ); + return reply(aiReply, nextStage); + } + + // ── Stage: awaiting MassHealth member ID + DOB ──────────────────────── + if (stage === "awaiting_masshealth_info") { + const { memberId, dob } = await parseMassHealthInfo(Body, aiSettings.apiKey); + + if (!memberId || !dob) { + // Couldn't parse — ask again with a clearer format hint + const retryMessages: Record = { + English: "I couldn't read your Member ID and date of birth. Please reply in this format: Member ID: 12345678 DOB: 01/01/1990", + Spanish: "No pude leer su número de miembro y fecha de nacimiento. Por favor responda así: ID: 12345678 Fecha: 01/01/1990", + Portuguese: "Não consegui ler seu número de membro e data de nascimento. Por favor responda assim: ID: 12345678 Data: 01/01/1990", + Mandarin: "我无法读取您的会员ID和出生日期。请按以下格式回复:ID: 12345678 生日: 01/01/1990", + Cantonese: "我無法讀取您的會員ID和出生日期。請按以下格式回覆:ID: 12345678 生日: 01/01/1990", + Arabic: "لم أتمكن من قراءة رقم العضوية وتاريخ الميلاد. يرجى الرد بالصيغة التالية: ID: 12345678 DOB: 01/01/1990", + "Haitian Creole": "Mwen pa t ka li ID manm ou ak dat nesans. Tanpri reponn konsa: ID: 12345678 DOB: 01/01/1990", + }; + const retryMsg = retryMessages[language] ?? retryMessages["English"]!; + return reply(retryMsg, "awaiting_masshealth_info"); + } + + // Immediately confirm to the patient and start the check in background + const checkingMessages: Record = { + English: "Thank you! I'm checking your MassHealth eligibility now. I'll send you the result in a moment.", + Spanish: "¡Gracias! Estoy verificando su elegibilidad de MassHealth ahora. Le enviaré el resultado en un momento.", + Portuguese: "Obrigado! Estou verificando sua elegibilidade MassHealth agora. Enviarei o resultado em instantes.", + Mandarin: "谢谢!我正在查询您的MassHealth资格。稍后我会发送结果给您。", + Cantonese: "多謝!我正在查詢您的MassHealth資格。稍後我會發送結果給您。", + Arabic: "شكراً! أقوم بالتحقق من أهليتك في MassHealth الآن. سأرسل لك النتيجة قريباً.", + "Haitian Creole": "Mèsi! Mwen ap verifye kalifikasyon MassHealth ou kounye a. M ap voye rezilta a nan yon ti moman.", + }; + const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!; + + // Reply now — Selenium runs in the background + await saveOutbound(patient.id, checkingMsg); + setStage(patient.userId, patient.id, "done"); + res.set("Content-Type", "text/xml"); + res.send(twimlReply(checkingMsg)); + + // Fire-and-forget: run check and send result SMS when complete + runMassHealthCheckAndNotify(patient, memberId, dob, aiSettings.apiKey).catch(() => {}); + return; + } + + // ── Stage: existing patient said YES to same insurance ─────────────── + // Special case: if they have MassHealth on file, run Selenium check + // automatically (we already have their member ID + DOB in DB). + if (stage === "asked_existing_insurance") { + const saysYes = /yes|same|still have|haven't changed|no change|yep|yeah|sí|si|sim|好的|نعم|wi/i.test(Body); + + if (saysYes) { + const patientRecord = await db.patient.findUnique({ + where: { id: patient.id }, + select: { insuranceProvider: true, insuranceId: true, dateOfBirth: true }, + }); + + const isMassHealth = /masshealth|mass health|masscare|medicaid/i.test( + patientRecord?.insuranceProvider ?? "" + ); + + if (isMassHealth && patientRecord?.insuranceId) { + // Format DOB as MM/DD/YYYY for Selenium + let dobStr = ""; + if (patientRecord.dateOfBirth) { + const d = new Date(patientRecord.dateOfBirth); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + const yy = d.getUTCFullYear(); + dobStr = `${mm}/${dd}/${yy}`; + } + + const checkingMessages: Record = { + English: "Please wait about 30-60 seconds! I'm double-checking your MassHealth coverage right now.", + Spanish: "¡Por favor espere unos 30-60 segundos! Estoy verificando su cobertura de MassHealth ahora mismo.", + Portuguese: "Por favor aguarde cerca de 30-60 segundos! Estou verificando sua cobertura MassHealth agora.", + Mandarin: "请等待约30-60秒!我现在正在为您核查MassHealth保险。", + Cantonese: "請等待約30-60秒!我現在正在為您核查MassHealth保險。", + Arabic: "يرجى الانتظار حوالي 30-60 ثانية! أقوم الآن بالتحقق من تغطيتك في MassHealth.", + "Haitian Creole": "Tanpri tann anviwon 30-60 segonn! Mwen ap verifye kouvèti MassHealth ou kounye a.", + }; + const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!; + + await saveOutbound(patient.id, checkingMsg); + setStage(patient.userId, patient.id, "done"); res.set("Content-Type", "text/xml"); - return res.send( - `${escapeXml(reply)}` - ); + res.send(twimlReply(checkingMsg)); + + // Fire-and-forget Selenium check; existing patient gets simpler result + runMassHealthCheckAndNotify( + patient, patientRecord.insuranceId, dobStr, aiSettings.apiKey, true + ).catch(() => {}); + return; } } + // Not MassHealth or said NO — fall through to normal graph handling + } + + // ── Stage: new_patient_greeted + multi-step new patient stages ──────── + const newPatientStages: ConversationStage[] = [ + "new_patient_greeted", "asked_new_or_existing", + "asked_new_patient_insurance", "asked_existing_insurance", + "asked_appointment_time", + "asked_appointment_preference", "asked_self_pay", + ]; + if (newPatientStages.includes(stage)) { + const { reply: aiReply, nextStage } = await runNewPatientStep( + Body, stage, language, aiSettings.apiKey + ); + return reply(aiReply, nextStage); + } + + // ── Stage: initial (no active conversation) ─────────────────────────── + // Check after-hours: if enabled and currently outside office hours → start new-patient flow + if (stage === "initial" || stage === "done") { + const afterHoursEnabled = getAfterHoursHandoff(patient.userId); + const outsideHours = await isAfterHours(patient.userId); + + if (afterHoursEnabled && outsideHours) { + const chatTemplates = await storage.getAiChatTemplates(patient.userId); + const officeContact = await storage.getOfficeContact(patient.userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + + const rawGreeting = chatTemplates.newPatientGreeting || + `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?`; + + return reply(applyOfficeName(rawGreeting, officeName), "new_patient_greeted"); + } } res.set("Content-Type", "text/xml"); - return res.send(""); + return res.send(empty()); + } catch (err) { res.set("Content-Type", "text/xml"); - return res.send(""); + return res.send(empty()); } }); -// POST /api/twilio/webhook/voice (Twilio posts here when someone calls — no auth) +// ── POST /api/twilio/webhook/voice ──────────────────────────────────────────── + router.post("/webhook/voice", async (req: Request, res: Response): Promise => { try { const { From, CallSid } = req.body; @@ -72,69 +450,51 @@ router.post("/webhook/voice", async (req: Request, res: Response): Promise let greeting = "Thank you for calling. Please leave a message after the beep and we will get back to you shortly."; if (patient) { const settings = await storage.getTwilioSettings(patient.userId); - if (settings?.greetingMessage?.trim()) { - greeting = settings.greetingMessage.trim(); - } + if (settings?.greetingMessage?.trim()) greeting = settings.greetingMessage.trim(); } if (patient) { await storage.createCommunication({ - patientId: patient.id, - channel: "voice", - direction: "inbound", - status: "completed", - body: "(Inbound call — voicemail below)", - twilioSid: CallSid, + patientId: patient.id, channel: "voice", direction: "inbound", + status: "completed", body: "(Inbound call — voicemail below)", twilioSid: CallSid, }); } const recordingCallbackUrl = `${process.env.BASE_URL || "https://communitydentistsoflowell.mydentalofficemanagement.com"}/api/twilio/webhook/voice-recording`; - const twiml = ` + res.set("Content-Type", "text/xml"); + return res.send(` ${greeting} We did not receive a recording. Goodbye. -`; - - res.set("Content-Type", "text/xml"); - return res.send(twiml); +`); } catch (err) { res.set("Content-Type", "text/xml"); return res.send(`Thank you for calling. Please try again later.`); } }); -// POST /api/twilio/webhook/voice-recording (Twilio posts recording URL here — no auth) +// ── POST /api/twilio/webhook/voice-recording ────────────────────────────────── + router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise => { try { const { CallSid, RecordingUrl } = req.body; - if (RecordingUrl && CallSid) { const comm = await db.communication.findFirst({ where: { twilioSid: CallSid } }); if (comm) { await db.communication.update({ where: { id: comm.id }, - data: { body: `Voicemail: ${RecordingUrl}.mp3` }, + data: { body: `Voicemail: ${RecordingUrl}.mp3` }, }); } } - res.set("Content-Type", "text/xml"); - return res.send(""); + return res.send(empty()); } catch (err) { res.set("Content-Type", "text/xml"); - return res.send(""); + return res.send(empty()); } }); -function escapeXml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - export default router; diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts index b94480b0..3529ae46 100644 --- a/apps/Backend/src/routes/twilio.ts +++ b/apps/Backend/src/routes/twilio.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from "express"; import twilio from "twilio"; import { storage } from "../storage"; +import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore"; const router = express.Router(); @@ -65,7 +66,7 @@ router.post("/send-sms", async (req: Request, res: Response): Promise => { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const { to, message, patientId } = req.body; + const { to, message, patientId, startFlow } = req.body; if (!to || !message) return res.status(400).json({ message: "to and message are required" }); const settings = await storage.getTwilioSettings(userId); @@ -81,8 +82,9 @@ router.post("/send-sms", async (req: Request, res: Response): Promise => { }); if (patientId) { + const pid = Number(patientId); await storage.createCommunication({ - patientId: Number(patientId), + patientId: pid, userId, channel: "sms", direction: "outbound", @@ -90,6 +92,12 @@ router.post("/send-sms", async (req: Request, res: Response): Promise => { body: message, twilioSid: twilioMsg.sid, }); + // Set conversation stage based on which flow was started + if (startFlow === "new_patient") { + startNewPatientConversation(userId, pid); + } else { + resetConversation(userId, pid); + } } return res.status(200).json({ sid: twilioMsg.sid, status: twilioMsg.status }); @@ -125,6 +133,60 @@ router.put("/templates/:key", async (req: Request, res: Response): Promise } }); +// GET /api/twilio/after-hours-handoff +router.get("/after-hours-handoff", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + return res.status(200).json({ enabled: getAfterHoursHandoff(userId) }); + } catch (err) { + return res.status(500).json({ error: "Failed to get after-hours handoff state" }); + } +}); + +// PUT /api/twilio/after-hours-handoff +router.put("/after-hours-handoff", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { enabled } = req.body; + if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" }); + setAfterHoursHandoff(userId, enabled); + return res.status(200).json({ enabled }); + } catch (err) { + return res.status(500).json({ error: "Failed to set after-hours handoff state" }); + } +}); + +// GET /api/twilio/ai-handoff/:patientId +router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const patientId = parseInt(req.params.patientId); + if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" }); + return res.status(200).json({ enabled: getHandoff(userId, patientId) }); + } catch (err) { + return res.status(500).json({ error: "Failed to get AI handoff state" }); + } +}); + +// PUT /api/twilio/ai-handoff/:patientId +router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const patientId = parseInt(req.params.patientId); + if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" }); + const { enabled } = req.body; + if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" }); + setHandoff(userId, patientId, enabled); + return res.status(200).json({ enabled }); + } catch (err) { + return res.status(500).json({ error: "Failed to set AI handoff state" }); + } +}); + // GET /api/twilio/recent-communications router.get("/recent-communications", async (req: Request, res: Response): Promise => { try { diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts index 9510b837..7a814d99 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -59,6 +59,30 @@ export const twilioStorage = { }); }, + async getAiChatTemplates(userId: number): Promise> { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const all = (settings?.templates as Record) || {}; + return { + reminderGreeting: all["_ai_chat_reminder_greeting"] ?? "", + newPatientGreeting: all["_ai_chat_new_patient_greeting"] ?? "", + generalFallback: all["_ai_chat_general_fallback"] ?? "", + }; + }, + + async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string }) { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const existing = (settings?.templates as Record) || {}; + const updated: Record = { ...existing }; + if (templates.reminderGreeting !== undefined) updated["_ai_chat_reminder_greeting"] = templates.reminderGreeting; + if (templates.newPatientGreeting !== undefined) updated["_ai_chat_new_patient_greeting"] = templates.newPatientGreeting; + if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback; + return 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/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index b5b4cb2c..ab039de4 100755 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -245,6 +245,11 @@ export function Sidebar() { path: "/settings/ai", icon: , }, + { + name: "AI Chat Settings", + path: "/settings/aichat", + icon: , + }, ], }, ], diff --git a/apps/Frontend/src/components/patient-connection/message-thread.tsx b/apps/Frontend/src/components/patient-connection/message-thread.tsx index 619616e8..c9f3faea 100755 --- a/apps/Frontend/src/components/patient-connection/message-thread.tsx +++ b/apps/Frontend/src/components/patient-connection/message-thread.tsx @@ -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(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({ 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 + + {/* New-patient flow indicator */} + {pendingStartFlow === "new_patient" && ( +
+ + New patient flow +
+ )} + + {/* AI handoff toggle */} +
+ + Hand off to AI + +
diff --git a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx new file mode 100644 index 00000000..b78b9b89 --- /dev/null +++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx @@ -0,0 +1,751 @@ +import { useState, useEffect, useRef } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type AiChatTemplates = { + reminderGreeting: string; + newPatientGreeting: string; + generalFallback: string; +}; + +type OfficeContact = { + officeName?: string | null; +}; + +// ─── Defaults ───────────────────────────────────────────────────────────────── + +const DEFAULTS = { + reminderGreeting: + "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply your message at any time you need.", + newPatientGreeting: + "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?", + generalFallback: "How can I help you today?", +}; + +function previewTemplate(text: string, officeName: string) { + return text.replace(/\{officeName\}/g, officeName || "your dental office"); +} + +// ─── LangGraph flow diagram (SVG) ───────────────────────────────────────────── + +function LangGraphFlow() { + const W = 620; + const cx = W / 2; // 310 + const nodeW = 200; + const nx = cx - nodeW / 2; // 210 + + // sequential node y positions & heights + const n1y = 16, n1h = 58; + const n2y = 116, n2h = 58; + const n3y = 216, n3h = 78; + + // fork geometry + const forkHY = n3y + n3h + 22; // 316 + const lcx = 150, rcx = 470; // branch centers (balanced around 310) + + // main branch nodes + const branchY = forkHY + 58; // 374 + const branchW = 220; + const branchH = 88; + const branchBottom = branchY + branchH; // 462 + + // ── Rescheduling sub-tree (below NO/left branch at lcx=150) ────────────── + const rsForkHY = branchBottom + 28; // 490 patient says YES/NO to reschedule + const rsYesCx = 90; + const rsNoCx = 215; + const rsNodeY = rsForkHY + 32; // 522 + const rsNodeH = 54; + + // ASAP-or-next-week fork (below rsYesCx=90) + const prefForkHY = rsNodeY + rsNodeH + 22; // 598 + const asakCx = 48; + const nwkCx = 138; + const prefNodeY = prefForkHY + 32; // 630 + const prefNodeH = 62; + + const totalH = prefNodeY + prefNodeH + 54 + 20 + 54 + 22; // time node + DB node + padding + + return ( + + + {/* Arrowhead marker */} + + + + + + {/* ── Node 1: Office sends reminder ─────────────────────────── */} + + Office sends reminder + Staff triggers the SMS + + {/* Arrow 1 → 2 */} + + + {/* ── Node 2: Patient replies ───────────────────────────────── */} + + Patient replies + Any message triggers the AI + + {/* Arrow 2 → 3 */} + + + {/* ── Node 3: AI introduces itself ──────────────────────────── */} + + AI introduces itself + "Hi! My name is Lisa, the dedicated + AI assistant at {"{officeName}"}..." + + {/* ── Fork connectors ───────────────────────────────────────── */} + {/* Vertical down from N3 */} + + {/* Horizontal fork line */} + + {/* Left drop → NO branch */} + + {/* Right drop → YES branch */} + + + {/* ── YES / NO badges on the fork ───────────────────────────── */} + {/* NO badge — left junction */} + + NO + + {/* YES badge — right junction */} + + YES + + {/* ── Left branch: NO → "Would you like to reschedule?" ───────── */} + + It is understandable! + Would you like to + reschedule? + + {/* ── Right branch: YES → Confirm ───────────────────────────── */} + + Thank you for + confirming! + "See you on [date & time]" + + {/* ══════════ RESCHEDULING SUB-TREE ══════════════════════════════ */} + + {/* Vertical from NO node bottom */} + + {/* Horizontal YES/NO fork */} + + + + + {/* YES / NO badges */} + + YES + + NO + + {/* YES → "ASAP or next week?" node */} + + ASAP or + next week? + (if Mon–Thu appt) + + {/* NO → polite close */} + + No problem! + Conversation ends + + {/* Vertical from "ASAP or next week?" down to preference fork */} + + {/* ASAP / Next week fork */} + + + + + {/* ASAP badge */} + + ASAP + {/* Next week badge */} + + Next week + + {/* ASAP → "Can you come tomorrow?" */} + + Can you come + tomorrow? + YES → ask time + + {/* Next week → "Mon, Tue, or Wed?" */} + + Mon, Tue, + or Wed? + → ask time + + {/* ── Time-slot node (shared by both paths) ─────────────────── */} + {/* Left vertical from ASAP node */} + + {/* Right vertical from NextWeek node */} + + {/* Horizontal converge line */} + + {/* Drop to time node */} + + + {/* Time-slot node */} + + Morning or + afternoon? + + {/* DB update node (dashed) */} + + + + DB Update + Appt. moved! + "See you on [day] at [time]" + + ); +} + +// ─── New Patient / After-Hours flow diagram ─────────────────────────────────── + +function NewPatientFlow() { + // ── Canvas ─────────────────────────────────────────────────────────────── + const W = 760; + const cx = 380; // top-node horizontal center + const nW = 220; + const nx = cx - nW / 2; // 270 + + // ── Sequential top nodes ───────────────────────────────────────────────── + const n1y = 15, n1h = 58; + const n2y = 115, n2h = 58; + const n3y = 215, n3h = 58; + const n4y = 315, n4h = 60; + + // ── Main fork ──────────────────────────────────────────────────────────── + const forkHY = n4y + n4h + 20; // 395 + const lcx = 165; // New Patient branch center + const rcx = 590; // Existing Patient branch center + const brW = 200; + + // ── Main branch nodes ───────────────────────────────────────────────────── + const brY = forkHY + 50; // 445 + const brH = 62; + + // ── Sub-forks ───────────────────────────────────────────────────────────── + const sfHY = brY + brH + 18; // 525 + const llcx = 100; const lrcx = 230; // New Patient sub-branches (centered on 165) + const rlcx = 510; const rrcx = 660; // Existing Patient sub-branches + + // ── Leaf nodes ──────────────────────────────────────────────────────────── + const leafY = sfHY + 46; // 571 + const leafW = 118; + const leafH = 80; + + // ── New patient: Selenium chain (below LL at cx=100) ───────────────────── + const npSelY = leafY + leafH + 34; // 685 — Selenium node top + const npSelW = 140; + const npSelH = 56; + const npResHY = npSelY + npSelH + 16; // 757 — result fork y + const npActCx = 52; // ACTIVE result center (100-48) + const npInaCx = 148; // INACTIVE result center (100+48) + const npResY = npResHY + 34; // 791 + const npResW = 80; + const npResH = 72; + + // ── Existing patient: YES → MassHealth auto-check (below RL at cx=510) ── + const exForkHY = leafY + leafH + 26; // 677 + const exMhCx = 445; // MassHealth sub-branch center + const exOtherCx = 558; // Other insurance center + const exCheckY = exForkHY + 34; // 711 + const exCheckW = 116; + const exCheckH = 52; + const exSelY = exCheckY + exCheckH + 18; // 781 + const exSelW = 128; + const exSelH = 52; + const exResHY = exSelY + exSelH + 16; // 849 + const exActCx = 401; // (445-44) + const exInaCx = 489; // (445+44) + const exLeafY = exResHY + 34; // 883 + const exLeafW = 80; + const exLeafH = 62; + + const totalH = Math.max(npResY + npResH, exLeafY + exLeafH) + 22; + + return ( + + + + + + + + {/* ═══════════ TOP SEQUENCE ═══════════════════════════════════ */} + + + Patient texts after hours + or staff selects + "Schedule a New Patient" + + + + + AI sends New Patient Greeting + "Hi! My name is Lisa..." + + + + + Patient replies + "I want an appointment / cleaning" + + + + + AI: New or existing patient? + "Are you a new or existing patient?" + + {/* ═══════════ MAIN FORK ══════════════════════════════════════ */} + + + + + + + + New Patient + + Existing Patient + + {/* ═══════════ LEFT BRANCH — NEW PATIENT ══════════════════════ */} + + + Do you have any + dental insurance? + + + + + + + + MassHealth + + No ins. + + {/* NP: MassHealth leaf — ask for ID+DOB */} + + Check MassHealth + "Text me your + Member ID & DOB" + + {/* NP: No insurance leaf */} + + Schedule + "When would + you like to come?" + + {/* NP: Selenium check (patient texts ID+DOB) */} + + + + Selenium + MassHealth Portal + Auto-checks eligibility + → result SMS to patient + + {/* NP: Post-Selenium ACTIVE/INACTIVE fork */} + + + + + + + ACTIVE + + INACTIVE + + + Check-up or + tooth problem? + → book appt + + + Inactive. + Self-pay? + YES/NO + + {/* ═══════════ RIGHT BRANCH — EXISTING PATIENT ════════════════ */} + + + Do you still have + the same insurance? + + + + + + + + YES + + NO + + {/* EP: YES — "Same insurance confirmed" */} + + Same insurance + MassHealth → auto-check + Other → schedule + + {/* EP: NO — Transfer */} + + Transfer + "Our staff will + assist you" + + {/* EP: YES → MassHealth sub-fork */} + + + + + + + MassHealth + + Other ins. + + {/* EP: MassHealth — "Checking 30-60s" node */} + + Wait 30-60 secs… + Using saved ID & DOB + + {/* EP: Other insurance — schedule directly */} + + Schedule + "When to come in?" + + {/* EP: MassHealth → Selenium */} + + + + Selenium + MassHealth Portal + Auto-checks eligibility + + {/* EP: ACTIVE / INACTIVE fork */} + + + + + + + ACTIVE + + INACTIVE + + + When to + come in? + → book appt + + + Inactive. + Self-pay? + YES/NO + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export function AiChatSettingsCard() { + const { toast } = useToast(); + + const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting); + const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting); + const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback); + const initialized = useRef(false); + + const { data: officeContact } = useQuery({ + queryKey: ["/api/office-contact"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/office-contact"); + if (!res.ok) return null; + return res.json(); + }, + staleTime: 60_000, + }); + + const { data: templates, isLoading } = useQuery({ + queryKey: ["/api/ai/chat-templates"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/ai/chat-templates"); + if (!res.ok) throw new Error("Failed to load templates"); + return res.json(); + }, + staleTime: Infinity, + refetchOnWindowFocus: false, + }); + + // Seed local state from server on first load only + useEffect(() => { + if (templates && !initialized.current) { + initialized.current = true; + setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting); + setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting); + setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback); + } + }, [templates]); + + const saveMutation = useMutation({ + mutationFn: async (data: AiChatTemplates) => { + const res = await apiRequest("PUT", "/api/ai/chat-templates", data); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message || "Failed to save templates"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/ai/chat-templates"] }); + toast({ title: "Templates saved", description: "AI chat templates have been updated." }); + }, + onError: (err: any) => { + toast({ title: "Error", description: err?.message, variant: "destructive" }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + saveMutation.mutate({ + reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting, + newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting, + generalFallback: generalFallback.trim() || DEFAULTS.generalFallback, + }); + }; + + const officeName = officeContact?.officeName?.trim() || ""; + + const templateFields = [ + { + key: "reminder", + icon: , + label: "Appointment Reminder Reply", + description: "Sent when the AI first introduces itself after an appointment reminder.", + value: reminderGreeting, + onChange: setReminderGreeting, + placeholder: DEFAULTS.reminderGreeting, + }, + { + key: "newPatient", + icon: , + label: "New Patient Greeting", + description: "Sent when a new patient texts in for the first time.", + value: newPatientGreeting, + onChange: setNewPatientGreeting, + placeholder: DEFAULTS.newPatientGreeting, + }, + { + key: "general", + icon: , + label: "General Fallback", + description: "Used when the AI cannot determine the context of the patient's message.", + value: generalFallback, + onChange: setGeneralFallback, + placeholder: DEFAULTS.generalFallback, + }, + ]; + + return ( +
+ + {/* ── Section 1: Chat Templates ────────────────────────────── */} + + +
+ +

Chat Templates

+
+

+ Customize how the AI assistant introduces itself to patients. Use{" "} + {"{officeName}"}{" "} + as a placeholder — it will be replaced with your dental office name automatically. +

+ + {officeName && ( +
+ + + {"{officeName}"} will display as{" "} + "{officeName}" + +
+ )} + + {isLoading ? ( +

Loading templates...

+ ) : ( +
+ {templateFields.map((f) => ( +
+
+ {f.icon} + {f.label} +
+

{f.description}

+