From 06dc63c53e4c2dd05fdc2a9c963a28bec37c7cbe Mon Sep 17 00:00:00 2001 From: ff Date: Fri, 31 Jul 2026 23:39:31 -0400 Subject: [PATCH] feat: unify AI voice calls with SMS conversation rules, add Call Templates AI phone calls now route through the same routePatientTurn() rule engine as SMS (office-hours checks, slot-conflict checks, MassHealth eligibility, staff escalation) instead of a freeform LLM chat, and speak in the patient's preferred language (English/Spanish). Adds a separate "Call Templates" settings section so spoken wording can be customized independently from SMS wording. Co-Authored-By: Claude Sonnet 5 --- .../src/ai/patient-conversation-router.ts | 640 +++++++++++++++ apps/Backend/src/ai/voice-assistant.ts | 59 -- apps/Backend/src/routes/ai-settings.ts | 30 +- apps/Backend/src/routes/twilio-webhooks.ts | 738 +++--------------- apps/Backend/src/storage/twilio-storage.ts | 35 +- .../settings/ai-chat-settings-card.tsx | 197 ++++- 6 files changed, 973 insertions(+), 726 deletions(-) create mode 100644 apps/Backend/src/ai/patient-conversation-router.ts diff --git a/apps/Backend/src/ai/patient-conversation-router.ts b/apps/Backend/src/ai/patient-conversation-router.ts new file mode 100644 index 00000000..2ff64d5e --- /dev/null +++ b/apps/Backend/src/ai/patient-conversation-router.ts @@ -0,0 +1,640 @@ +import twilio from "twilio"; +import { prisma as db } from "@repo/db/client"; +import { storage } from "../storage"; +import { runReminderGraph } from "./reminder-graph"; +import { runNewPatientStep } from "./new-patient-graph"; +import { + runRescheduleStep, + parseDateOnlyFromMessage, + parseTime, + isOfficeDayOpen, + isWithinOfficeHours, + getOfficeHoursDisplay, + timeLabel, +} from "./reschedule-graph"; +import { getLlm, type AiProvider } from "./llm-factory"; +import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor"; +import { + getPendingReschedule, setPendingReschedule, clearPendingReschedule, + setStage, type ConversationStage, +} from "./aiHandoffStore"; + +// Shared, channel-agnostic implementation of the patient conversation rules +// (reminder / reschedule / new-patient graphs, plus the inline MassHealth and +// appointment-booking stages) used by both the SMS webhook and the AI voice +// call webhook, so a patient gets identical guardrails and outcomes +// regardless of channel. + +export interface RoutedPatient { + id: number; + userId: number; + phone: string | null; + preferredLanguage: string | null; +} + +export interface RouteTurnResult { + segments: string[]; + nextStage: ConversationStage; +} + +/** Get the patient's next scheduled appointment as a human-readable string. */ +export 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}`; +} + +/** Substitute {officeName} in a template string. */ +export function applyOfficeName(template: string, name: string): string { + return template.replace(/\{officeName\}/g, name || "our dental office"); +} + +/** Save an outbound SMS message (always sms channel — used for async follow-ups). */ +async function saveOutboundSms(patientId: number, body: string): Promise { + await storage.createCommunication({ + patientId, channel: "sms", direction: "outbound", status: "sent", body, + }); +} + +/** Normalize a DOB string to zero-padded MM/DD/YYYY required by MassHealth. */ +function normalizeDob(raw: string): string { + const parts = raw.split(/[\/\-\.]/); + if (parts.length !== 3) return raw; + const [m, d, y] = parts; + const mm = String(parseInt(m!, 10)).padStart(2, "0"); + const dd = String(parseInt(d!, 10)).padStart(2, "0"); + const yyyy = y!.length === 2 ? `20${y}` : y!; + return `${mm}/${dd}/${yyyy}`; +} + +/** + * Extract MassHealth Member ID and date of birth from free text. + * Tries regex first, falls back to LLM extraction. + */ +async function parseMassHealthInfo( + message: string, + apiKey: string, + provider: AiProvider = "google", + model?: string +): Promise<{ memberId: string | null; dob: string | null }> { + 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: normalizeDob(`${m}/${d}/${year}`) }; + } + + try { + const llm = getLlm(provider, apiKey, model); + 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); + const dob = json.dob ? normalizeDob(String(json.dob)) : null; + return { memberId: json.memberId ?? null, dob }; + } catch { + return { memberId: null, dob: null }; + } +} + +/** + * Run MassHealth eligibility check in the background (after replying to the + * patient) and send the result as a follow-up SMS, regardless of which + * channel (SMS or voice call) triggered the check. + */ +async function runMassHealthCheckAndNotify( + patient: RoutedPatient, + 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; + + await runEligibilityProcessor({ + userId: patient.userId, + insuranceId: memberId, + formDob: dob, + enrichedPayload: { + memberId, + dateOfBirth: dob, + insuranceSiteKey: "MH", + massdhpUsername: credentials.username, + massdhpPassword: credentials.password, + }, + }); + + 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"; + + const activeMessages: Record = { + English: "Great news! Your MassHealth coverage is active. We can schedule an appointment for you! What date and time would you prefer?", + Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¡Podemos programar una cita para usted! ¿Qué fecha y hora prefiere?", + Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Podemos agendar uma consulta para você! Qual data e horário prefere?", + Mandarin: "好消息!您的MassHealth保险有效。我们可以为您安排预约!您希望什么日期和时间?", + Cantonese: "好消息!您的MassHealth保險有效。我們可以為您安排預約!您希望什麼日期和時間?", + Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. يمكننا تحديد موعد لك! ما التاريخ والوقت المفضل لديك؟", + "Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Nou ka planifye yon randevou pou ou! Ki dat ak lè ou prefere?", + }; + + const inactiveMessagesNew: Record = { + English: "Unfortunately, your MassHealth coverage appears to be inactive. Do you have any other insurance?", + Spanish: "Lamentablemente, su cobertura de MassHealth parece estar inactiva. ¿Tiene algún otro seguro?", + Portuguese: "Infelizmente, sua cobertura MassHealth parece estar inativa. Você tem algum outro plano de saúde?", + Mandarin: "很遗憾,您的MassHealth保险似乎无效。您还有其他保险吗?", + Cantonese: "很遺憾,您的MassHealth保險似乎無效。您還有其他保險嗎?", + Arabic: "للأسف، تغطيتك من MassHealth تبدو غير نشطة. هل لديك أي تأمين آخر؟", + "Haitian Creole": "Malerezman, kouvèti MassHealth ou parèt inaktif. Èske ou gen yon lòt asirans?", + }; + + const inactiveMessagesExisting: 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"]!) + : isExistingPatient + ? (inactiveMessagesExisting[lang] ?? inactiveMessagesExisting["English"]!) + : (inactiveMessagesNew[lang] ?? inactiveMessagesNew["English"]!); + + const nextStage: ConversationStage = active + ? "asked_appointment_time" + : isExistingPatient + ? "asked_self_pay" + : "asked_other_insurance_after_inactive"; + + const client = twilio(twilioSettings.accountSid, twilioSettings.authToken); + await client.messages.create({ + body: resultText, + from: twilioSettings.phoneNumber, + to: patient.phone, + }); + + await saveOutboundSms(patient.id, resultText); + await setStage(patient.userId, patient.id, nextStage); + + } catch { + // Silent — don't crash the caller + } +} + +async function finish( + userId: number, patientId: number, + segments: string[], nextStage: ConversationStage +): Promise { + await setStage(userId, patientId, nextStage); + return { segments, nextStage }; +} + +/** + * Process one patient message (SMS body or a transcribed spoken turn) against + * the current ConversationStage and return what to say next plus the stage + * to advance to. Persists the resulting stage itself. Does NOT log the + * Communication row for the reply — callers do that per-channel. + */ +export async function routePatientTurn(params: { + patient: RoutedPatient; + stage: ConversationStage; + message: string; + activeAi: { provider: AiProvider; key: string; model: string }; + chatTemplates: Record; + officeName: string; +}): Promise { + const { patient, stage, message, activeAi, chatTemplates, officeName } = params; + const language = patient.preferredLanguage || "English"; + + // ── Stage: reminder_initial → self-intro + classify yes/no/other ──────── + if (stage === "reminder_initial") { + 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.`; + const introText = applyOfficeName(rawGreeting, officeName); + + const apptDatetime = await getAppointmentDatetime(patient.id); + const { reply: intentReply, intent } = await runReminderGraph( + message, activeAi.key, language, apptDatetime, + chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model + ); + + if (intentReply) { + let nextStage: ConversationStage; + if (intent === "no") nextStage = "asked_reschedule_datetime"; + else if (intent === "wants_appointment") nextStage = "asked_new_or_existing"; + else nextStage = "done"; + + if (intent === "no") { + const hasDateInMessage = + /\b\d{1,2}[\/\-]\d{1,2}\b/.test(message) || + /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(message); + if (hasDateInMessage) { + const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep( + message, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model + ); + return finish(patient.userId, patient.id, [introText, rescheduleReply], rescheduleNextStage); + } + } + + return finish(patient.userId, patient.id, [introText, intentReply], nextStage); + } + + // No clear intent detected — send only the intro and wait for next reply + return finish(patient.userId, patient.id, [introText], "greeted"); + } + + // ── Stage: greeted → classify yes/no for appointment reminder ────────── + if (stage === "greeted") { + const apptDatetime = await getAppointmentDatetime(patient.id); + const { reply: aiReply, intent } = await runReminderGraph( + message, activeAi.key, language, apptDatetime, + chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model + ); + if (aiReply) { + let nextStage: ConversationStage; + if (intent === "no") nextStage = "asked_reschedule_datetime"; + else if (intent === "wants_appointment") nextStage = "asked_new_or_existing"; + else nextStage = "done"; + + if (intent === "no") { + const hasDateInMessage = + /\b\d{1,2}[\/\-]\d{1,2}\b/.test(message) || + /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(message); + if (hasDateInMessage) { + const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep( + message, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model + ); + return finish(patient.userId, patient.id, [rescheduleReply], rescheduleNextStage); + } + } + + return finish(patient.userId, patient.id, [aiReply], nextStage); + } + } + + // ── Rescheduling flow stages ──────────────────────────────────────────── + const rescheduleStages: ConversationStage[] = [ + "asked_reschedule_confirm", "asked_reschedule_preference", + "asked_reschedule_asap", "asked_reschedule_next_week", + "asked_reschedule_time", "asked_reschedule_datetime", + "asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime", + ]; + if (rescheduleStages.includes(stage)) { + const { reply: aiReply, nextStage } = await runRescheduleStep( + message, stage, language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model + ); + return finish(patient.userId, patient.id, [aiReply], nextStage); + } + + // ── Stage: awaiting MassHealth member ID + DOB ───────────────────────── + if (stage === "awaiting_masshealth_info") { + const { memberId, dob } = await parseMassHealthInfo(message, activeAi.key, activeAi.provider, activeAi.model); + + if (!memberId || !dob) { + 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", + }; + return finish(patient.userId, patient.id, [retryMessages[language] ?? retryMessages["English"]!], "awaiting_masshealth_info"); + } + + 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"]!; + + runMassHealthCheckAndNotify(patient, memberId, dob, activeAi.key).catch(() => {}); + return finish(patient.userId, patient.id, [checkingMsg], "done"); + } + + // ── Stage: existing patient said YES to same insurance ────────────────── + if (stage === "asked_existing_insurance") { + const saysYes = /yes|same|still have|haven't changed|no change|yep|yeah|sí|si|sim|好的|نعم|wi/i.test(message); + + 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) { + 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"]!; + + runMassHealthCheckAndNotify(patient, patientRecord.insuranceId, dobStr, activeAi.key, true).catch(() => {}); + return finish(patient.userId, patient.id, [checkingMsg], "done"); + } + } + // Not MassHealth or said NO — fall through to normal graph handling + } + + // ── Stage: asked_new_or_reschedule ─────────────────────────────────────── + if (stage === "asked_new_or_reschedule") { + const isReschedule = /reschedule|rescheduler|change|modify|move|different|reprogramar|reagendar|cambiar|mudar|\b2\b/i.test(message); + const isNew = /new.*appoint|make.*appoint|book.*appoint|new patient|first.*time|nueva cita|nova consulta|\b1\b/i.test(message); + + if (isReschedule) { + const apptDatetime = await getAppointmentDatetime(patient.id); + if (apptDatetime) { + const foundMsgs: Record = { + English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`, + Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`, + Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`, + Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`, + Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`, + Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`, + "Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`, + }; + return finish(patient.userId, patient.id, [foundMsgs[language] ?? foundMsgs["English"]!], "asked_reschedule_datetime"); + } else { + const notFoundMsgs: Record = { + English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.", + Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.", + Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.", + Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。", + Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。", + Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.", + "Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.", + }; + return finish(patient.userId, patient.id, [notFoundMsgs[language] ?? notFoundMsgs["English"]!], "done"); + } + } + + void isNew; // ambiguous or explicit "new" both fall through to the same prompt + const newApptMsgs: Record = { + English: "No problem! To get started, are you an existing patient or a new patient?", + Spanish: "¡Sin problema! Para comenzar, ¿es usted un paciente existente o un paciente nuevo?", + Portuguese: "Sem problema! Para começar, você é um paciente existente ou um novo paciente?", + Mandarin: "没问题!请问您是现有患者还是新患者?", + Cantonese: "沒問題!請問您是現有病人還是新病人?", + Arabic: "لا مشكلة! للبدء، هل أنت مريض حالي أم مريض جديد؟", + "Haitian Creole": "Pa gen pwoblèm! Pou kòmanse, èske ou se yon pasyan egzistan oswa yon nouvo pasyan?", + }; + return finish(patient.userId, patient.id, [newApptMsgs[language] ?? newApptMsgs["English"]!], "asked_new_or_existing"); + } + + // ── Stage: asked_appointment_time → parse date, check office hours ───── + if (stage === "asked_appointment_time") { + const parsedDate = await parseDateOnlyFromMessage(message, activeAi.key, activeAi.provider, activeAi.model); + if (!parsedDate) { + const msgs: Record = { + English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.", + Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.", + Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.", + Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。", + Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。", + Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.", + "Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.", + }; + return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_appointment_time"); + } + const { date, dateLabel } = parsedDate; + const dayCheck = await isOfficeDayOpen(date, patient.userId); + if (!dayCheck.open) { + const msgs: Record = { + English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`, + Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`, + Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`, + Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`, + Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`, + Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`, + "Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`, + }; + return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_appointment_time"); + } + setPendingReschedule(patient.userId, patient.id, { newDate: date, dayLabel: dateLabel }); + const askTimeMsgs: Record = { + English: `What time do you prefer on ${dateLabel}?`, + Spanish: `¿A qué hora prefiere el ${dateLabel}?`, + Portuguese: `Que horário você prefere em ${dateLabel}?`, + Mandarin: `您希望在 ${dateLabel} 几点?`, + Cantonese: `您希望在 ${dateLabel} 幾點?`, + Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`, + "Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`, + }; + return finish(patient.userId, patient.id, [askTimeMsgs[language] ?? askTimeMsgs["English"]!], "asked_new_appt_time_for_date"); + } + + // ── Stage: asked_new_appt_time_for_date → parse time, check hours ────── + if (stage === "asked_new_appt_time_for_date") { + const pending = getPendingReschedule(patient.userId, patient.id); + if (!pending) { + return finish(patient.userId, patient.id, ["I lost track of the date. What day would you prefer?"], "asked_appointment_time"); + } + const startTime = await parseTime(message, activeAi.key, activeAi.provider, activeAi.model); + if (!startTime) { + const msgs: Record = { + English: `I didn't catch the time. What time do you prefer on ${pending.dayLabel}? For example: '10am' or '2pm'.`, + Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am' o '2pm'.`, + Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h' ou '14h'.`, + Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点?例如:上午10点或下午2点。`, + Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點?例如:上午10點或下午2點。`, + Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`, + "Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am oswa 2pm.`, + }; + return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_new_appt_time_for_date"); + } + const withinHours = await isWithinOfficeHours(pending.newDate, startTime, patient.userId); + if (!withinHours) { + const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, patient.userId); + const timeLbl = timeLabel(startTime); + const msgs: Record = { + English: hoursDisplay + ? `Our office is not available at ${timeLbl} on ${pending.dayLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?` + : `Our office is not available at ${timeLbl} on ${pending.dayLabel}. What other time do you prefer?`, + Spanish: hoursDisplay + ? `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?` + : `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. ¿Qué otro horario prefiere?`, + Portuguese: hoursDisplay + ? `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?` + : `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Que outro horário você prefere?`, + Mandarin: hoursDisplay + ? `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?` + : `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。您希望改什么时间?`, + Cantonese: hoursDisplay + ? `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?` + : `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。您希望改什麼時間?`, + Arabic: hoursDisplay + ? `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟` + : `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ما وقت آخر تفضله؟`, + "Haitian Creole": hoursDisplay + ? `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?` + : `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Ki lòt lè ou prefere?`, + }; + return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_new_appt_time_for_date"); + } + clearPendingReschedule(patient.userId, patient.id); + const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`; + + const [nh, nm] = startTime.split(":").map(Number); + const endTotal = nh! * 60 + nm! + 60; + const endTime = `${String(Math.floor(endTotal / 60)).padStart(2, "0")}:${String(endTotal % 60).padStart(2, "0")}`; + + const [patientRecord, firstStaff] = await Promise.all([ + db.patient.findUnique({ + where: { id: patient.id }, + select: { firstName: true, lastName: true }, + }), + db.staff.findFirst({ + where: { userId: patient.userId }, + orderBy: { id: "asc" }, + }), + ]); + + if (firstStaff) { + try { + await storage.createAppointment({ + patientId: patient.id, + userId: patient.userId, + staffId: firstStaff.id, + title: `AI Scheduled - ${(patientRecord?.firstName ?? "") + " " + (patientRecord?.lastName ?? "")}`.trim(), + date: pending.newDate, + startTime, + endTime, + type: "checkup", + status: "scheduled", + movedByAi: true, + } as any); + } catch { /* silent — message still sent, staff can create manually */ } + } + + const confirmMsgs: Record = { + English: `Thank you! Your preferred appointment at ${apptLabel} was scheduled. Our receptionist will confirm it with you shortly.`, + Spanish: `¡Gracias! Su cita preferida el ${apptLabel} fue programada. Nuestra recepcionista lo confirmará con usted en breve.`, + Portuguese: `Obrigado! Sua consulta preferida em ${apptLabel} foi agendada. Nossa recepcionista confirmará com você em breve.`, + Mandarin: `谢谢!您在 ${apptLabel} 的预约已安排。我们的前台将很快与您确认。`, + Cantonese: `多謝!您在 ${apptLabel} 的預約已安排。我們的接待員將很快與您確認。`, + Arabic: `شكراً! تم جدولة موعدك في ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيده.`, + "Haitian Creole": `Mèsi! Randevou ou a nan ${apptLabel} pwograme. Resepsyonis nou an pral konfime li ak ou byento.`, + }; + return finish(patient.userId, patient.id, [confirmMsgs[language] ?? confirmMsgs["English"]!], "done"); + } + + // ── Stage: new_patient_greeted + multi-step new patient stages ────────── + const newPatientStages: ConversationStage[] = [ + "new_patient_greeted", "asked_new_or_existing", + "asked_new_patient_insurance", "asked_insurance_type", + "asked_masshealth_check_consent", "asked_existing_insurance", + "asked_appointment_preference", + "asked_self_pay", "asked_other_insurance_after_inactive", + "collecting_contact_info", + ]; + if (newPatientStages.includes(stage)) { + const { reply: aiReply, nextStage } = await runNewPatientStep( + message, stage, language, activeAi.key, chatTemplates.generalFallback, activeAi.provider, activeAi.model + ); + return finish(patient.userId, patient.id, [aiReply], nextStage); + } + + // ── Stage: done → closing thank-you reply ──────────────────────────────── + if (stage === "done") { + const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(message); + if (isThanks) { + const apptDatetime = await getAppointmentDatetime(patient.id); + const CLOSING: Record = { + English: apptDatetime + ? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.` + : `Thank you for choosing our office! We look forward to seeing you soon.`, + Spanish: apptDatetime + ? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.` + : `¡Gracias por elegirnos! Le esperamos pronto.`, + Portuguese: apptDatetime + ? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.` + : `Obrigado por nos escolher! Aguardamos sua visita em breve.`, + Mandarin: apptDatetime + ? `感谢您选择我们!期待在 ${apptDatetime} 见到您。` + : `感谢您选择我们!期待很快见到您。`, + Cantonese: apptDatetime + ? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。` + : `感謝您選擇我們!期待很快見到您。`, + Arabic: apptDatetime + ? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.` + : `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`, + "Haitian Creole": apptDatetime + ? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.` + : `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`, + }; + const fallback = CLOSING[language] ?? CLOSING["English"]!; + if (apptDatetime) { + try { + const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model); + const res = await llm.invoke([ + { + role: "system", + content: `You are a friendly dental office AI assistant. The patient just said "${message}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`, + }, + { role: "user", content: message }, + ]); + const aiMsg = String(res.content).trim(); + if (aiMsg) return finish(patient.userId, patient.id, [aiMsg], "done"); + } catch { /* fall through to fallback */ } + } + return finish(patient.userId, patient.id, [fallback], "done"); + } + } + + // No matching stage/branch — hold stage steady with no reply. + return { segments: [], nextStage: stage }; +} diff --git a/apps/Backend/src/ai/voice-assistant.ts b/apps/Backend/src/ai/voice-assistant.ts index b5257e86..f56c11f7 100644 --- a/apps/Backend/src/ai/voice-assistant.ts +++ b/apps/Backend/src/ai/voice-assistant.ts @@ -1,65 +1,6 @@ -import { getLlm, type AiProvider } from "./llm-factory"; - -export interface VoiceTurn { - role: "assistant" | "user"; - text: string; -} - -export interface VoiceAssistantContext { - firstName: string; - officeName: string; - officeAddress: string; - officePhone: string; - appointmentDatetime: string; -} - const CLOSING_PATTERNS = /\b(bye|goodbye|good bye|that'?s all|nothing else|no thanks|no thank you|i'?m good|that'?s it|hang up)\b/i; export function soundsLikeGoodbye(text: string): boolean { return CLOSING_PATTERNS.test(text.trim()); } - -/** - * Generate Lisa's next spoken reply given the call transcript so far. - * Falls back to a plain closing line if the LLM call fails, so a broken - * key never leaves the caller stuck mid-call. - */ -export async function runVoiceAssistantTurn( - history: VoiceTurn[], - ctx: VoiceAssistantContext, - apiKey: string, - provider: AiProvider = "google", - model?: string -): Promise { - const fallback = - "I'm sorry, I'm having trouble right now — our staff will follow up with you shortly. Thank you for calling, goodbye."; - - try { - const llm = getLlm(provider, apiKey, model); - const system = [ - `You are Lisa, a friendly AI phone assistant for ${ctx.officeName || "a dental office"}.`, - `You are speaking live by phone with ${ctx.firstName || "the patient"}.`, - ctx.appointmentDatetime - ? `Their next appointment is on ${ctx.appointmentDatetime}.` - : `They have no upcoming appointment on file.`, - ctx.officeAddress ? `Office address: ${ctx.officeAddress}.` : "", - ctx.officePhone ? `Office phone: ${ctx.officePhone}.` : "", - "You can confirm their appointment and answer general questions about the practice.", - "If they want to reschedule or need something you can't resolve yourself, tell them a staff member will call or text them to confirm — never claim you rebooked or changed anything yourself.", - "This reply will be read aloud by text-to-speech: keep it conversational and SHORT, 1-2 sentences, no formatting, no lists.", - "If they indicate they're done (goodbye, that's all, etc.), give a brief warm goodbye.", - ].filter(Boolean).join(" "); - - const messages = [ - { role: "system", content: system }, - ...history.map((t) => ({ role: t.role === "assistant" ? "assistant" : "user", content: t.text })), - ]; - - const res = await llm.invoke(messages as any); - const text = String(res.content).trim(); - return text || fallback; - } catch { - return fallback; - } -} diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index d9712695..8445d138 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -214,8 +214,8 @@ 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, rescheduleGreeting, reminderSms, callTemplate } = req.body; - await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms, callTemplate }); + const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms } = req.body; + await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms }); const updated = await storage.getAiChatTemplates(userId); return res.status(200).json(updated); } catch (err) { @@ -223,6 +223,32 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise } }); +// GET /api/ai/call-templates +router.get("/call-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.getAiCallTemplates(userId); + return res.status(200).json(templates); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch AI call templates", details: String(err) }); + } +}); + +// PUT /api/ai/call-templates +router.put("/call-templates", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { greeting, reminderGreeting, newPatientGreeting, generalFallback } = req.body; + await storage.saveAiCallTemplates(userId, { greeting, reminderGreeting, newPatientGreeting, generalFallback }); + const updated = await storage.getAiCallTemplates(userId); + return res.status(200).json(updated); + } catch (err) { + return res.status(500).json({ error: "Failed to save AI call templates", details: String(err) }); + } +}); + // GET /api/ai/internal-chat-settings router.get("/internal-chat-settings", async (req: Request, res: Response): Promise => { try { diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index 33e06fe2..a4b813e0 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -2,10 +2,8 @@ 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, parseDateOnlyFromMessage, parseTime, isOfficeDayOpen, @@ -14,12 +12,14 @@ import { timeLabel, } from "../ai/reschedule-graph"; import { getLlm, resolveAiProvider } from "../ai/llm-factory"; -import { runVoiceAssistantTurn, soundsLikeGoodbye } from "../ai/voice-assistant"; +import { soundsLikeGoodbye } from "../ai/voice-assistant"; import { getPublicBaseUrl } from "../utils/publicUrl"; -import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor"; +import { + routePatientTurn, getAppointmentDatetime, applyOfficeName, +} from "../ai/patient-conversation-router"; import { getHandoff, getAfterHoursHandoff, - getStage, setStage, + getStage, setStage, resetConversation, setPendingReschedule, getPendingReschedule, clearPendingReschedule, type ConversationStage, } from "../ai/aiHandoffStore"; @@ -48,21 +48,6 @@ 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); @@ -82,11 +67,6 @@ async function isAfterHours(userId: number): Promise { 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({ @@ -94,160 +74,6 @@ async function saveOutbound(patientId: number, body: string): Promise { }); } -/** - * Extract MassHealth Member ID and date of birth from a free-text SMS. - * Tries regex first, falls back to LLM extraction. - */ -/** Normalize a DOB string to zero-padded MM/DD/YYYY required by MassHealth. */ -function normalizeDob(raw: string): string { - const parts = raw.split(/[\/\-\.]/); - if (parts.length !== 3) return raw; - const [m, d, y] = parts; - const mm = String(parseInt(m!, 10)).padStart(2, "0"); - const dd = String(parseInt(d!, 10)).padStart(2, "0"); - const yyyy = y!.length === 2 ? `20${y}` : y!; - return `${mm}/${dd}/${yyyy}`; -} - -async function parseMassHealthInfo( - message: string, - apiKey: string, - provider: import("../ai/llm-factory").AiProvider = "google", - model?: 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: normalizeDob(`${m}/${d}/${year}`) }; - } - - // Fall back to LLM structured extraction - try { - const llm = getLlm(provider, apiKey, model); - 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); - const dob = json.dob ? normalizeDob(String(json.dob)) : null; - return { memberId: json.memberId ?? null, dob }; - } 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 ──────────────────────────────────────────────────────────────── - const activeMessages: Record = { - English: "Great news! Your MassHealth coverage is active. We can schedule an appointment for you! What date and time would you prefer?", - Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¡Podemos programar una cita para usted! ¿Qué fecha y hora prefiere?", - Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Podemos agendar uma consulta para você! Qual data e horário prefere?", - Mandarin: "好消息!您的MassHealth保险有效。我们可以为您安排预约!您希望什么日期和时间?", - Cantonese: "好消息!您的MassHealth保險有效。我們可以為您安排預約!您希望什麼日期和時間?", - Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. يمكننا تحديد موعد لك! ما التاريخ والوقت المفضل لديك؟", - "Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Nou ka planifye yon randevou pou ou! Ki dat ak lè ou prefere?", - }; - - // ── INACTIVE: new patient → ask other insurance; existing → ask self-pay ── - const inactiveMessagesNew: Record = { - English: "Unfortunately, your MassHealth coverage appears to be inactive. Do you have any other insurance?", - Spanish: "Lamentablemente, su cobertura de MassHealth parece estar inactiva. ¿Tiene algún otro seguro?", - Portuguese: "Infelizmente, sua cobertura MassHealth parece estar inativa. Você tem algum outro plano de saúde?", - Mandarin: "很遗憾,您的MassHealth保险似乎无效。您还有其他保险吗?", - Cantonese: "很遺憾,您的MassHealth保險似乎無效。您還有其他保險嗎?", - Arabic: "للأسف، تغطيتك من MassHealth تبدو غير نشطة. هل لديك أي تأمين آخر؟", - "Haitian Creole": "Malerezman, kouvèti MassHealth ou parèt inaktif. Èske ou gen yon lòt asirans?", - }; - - const inactiveMessagesExisting: 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"]!) - : isExistingPatient - ? (inactiveMessagesExisting[lang] ?? inactiveMessagesExisting["English"]!) - : (inactiveMessagesNew[lang] ?? inactiveMessagesNew["English"]!); - - const nextStage: ConversationStage = active - ? "asked_appointment_time" - : isExistingPatient - ? "asked_self_pay" - : "asked_other_insurance_after_inactive"; - - // 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); - await setStage(patient.userId, patient.id, nextStage); - - } catch { - // Silent — don't crash the main request - } -} - // ── Empathetic one-liner (instant keyword-based, no API latency) ───────────── function getEmpatheticAck(message: string, language: string): string { @@ -615,451 +441,31 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise => const officeContact = await storage.getOfficeContact(patient.userId); const officeName = (officeContact as any)?.officeName?.trim() || ""; - // ── Helper: send reply + set stage ───────────────────────────────────── - const reply = async (text: string, nextStage: ConversationStage) => { - await saveOutbound(patient.id, text); - await setStage(patient.userId, patient.id, nextStage); - res.set("Content-Type", "text/xml"); - return res.send(twimlReply(text)); - }; + // ── Shared business rules (reminder/reschedule/new-patient graphs, plus the + // MassHealth and appointment-booking stages) — the exact same code path the + // AI voice call webhook uses, so a patient gets identical guardrails and + // outcomes regardless of channel. + const routed = await routePatientTurn({ + patient, stage, message: Body, activeAi, chatTemplates, officeName, + }); - // ── Stage: reminder_initial → two messages: 1) AI intro, 2) intent response ── - if (stage === "reminder_initial") { - 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.`; - const introText = applyOfficeName(rawGreeting, officeName); + if (routed.segments.length > 0) { + for (const seg of routed.segments) await saveOutbound(patient.id, seg); - // Use Google AI (LangGraph) to read the patient's reply and classify yes/no - const apptDatetime = await getAppointmentDatetime(patient.id); - const { reply: intentReply, intent } = await runReminderGraph( - Body, activeAi.key, language, apptDatetime, - chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model - ); - - if (intentReply) { - let nextStage: ConversationStage; - if (intent === "no") nextStage = "asked_reschedule_datetime"; - else if (intent === "wants_appointment") nextStage = "asked_new_or_existing"; - else nextStage = "done"; - - // Send message 1 (AI intro) via REST API — queued FIRST in Twilio so it arrives first + // reminder_initial's self-intro (segments[0]) is queued via REST so it's + // guaranteed to arrive before the classified reply that follows it. + if (stage === "reminder_initial" && routed.segments.length > 1) { const twilioSettings = await storage.getTwilioSettings(patient.userId); if (twilioSettings) { const client = twilio(twilioSettings.accountSid, twilioSettings.authToken); - await client.messages.create({ body: introText, from: twilioSettings.phoneNumber, to: From }); - await saveOutbound(patient.id, introText); + await client.messages.create({ body: routed.segments[0]!, from: twilioSettings.phoneNumber, to: From }); } - - // If patient said "no" but already included a date (e.g. "no, 5/18"), - // skip "when to reschedule?" and go straight to date processing - if (intent === "no") { - const hasDateInMessage = - /\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) || - /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body); - if (hasDateInMessage) { - const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep( - Body, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model - ); - return reply(rescheduleReply, rescheduleNextStage); - } - } - - // Send message 2 (yes/no response) via TwiML — queued SECOND - return reply(intentReply, nextStage); + res.set("Content-Type", "text/xml"); + return res.send(twimlMessages(...routed.segments.slice(1))); } - // No clear intent detected — send only the intro and wait for next reply - return reply(introText, "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, activeAi.key, language, apptDatetime, - chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model - ); - if (aiReply) { - let nextStage: ConversationStage; - if (intent === "no") nextStage = "asked_reschedule_datetime"; - else if (intent === "wants_appointment") nextStage = "asked_new_or_existing"; - else nextStage = "done"; - - // If patient said "no" but already included a date, skip straight to date processing - if (intent === "no") { - const hasDateInMessage = - /\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) || - /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body); - if (hasDateInMessage) { - const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep( - Body, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model - ); - return reply(rescheduleReply, rescheduleNextStage); - } - } - - 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", "asked_reschedule_datetime", - "asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime", - ]; - if (rescheduleStages.includes(stage)) { - const { reply: aiReply, nextStage } = await runRescheduleStep( - Body, stage, language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model - ); - return reply(aiReply, nextStage); - } - - // ── Stage: awaiting MassHealth member ID + DOB ──────────────────────── - if (stage === "awaiting_masshealth_info") { - const { memberId, dob } = await parseMassHealthInfo(Body, activeAi.key, activeAi.provider, activeAi.model); - - 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); - await 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, activeAi.key).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); - await setStage(patient.userId, patient.id, "done"); - res.set("Content-Type", "text/xml"); - res.send(twimlReply(checkingMsg)); - - // Fire-and-forget Selenium check; existing patient gets simpler result - runMassHealthCheckAndNotify( - patient, patientRecord.insuranceId, dobStr, activeAi.key, true - ).catch(() => {}); - return; - } - } - // Not MassHealth or said NO — fall through to normal graph handling - } - - // ── Stage: asked_new_or_reschedule ──────────────────────────────────── - if (stage === "asked_new_or_reschedule") { - const isReschedule = /reschedule|rescheduler|change|modify|move|different|reprogramar|reagendar|cambiar|mudar|\b2\b/i.test(Body); - const isNew = /new.*appoint|make.*appoint|book.*appoint|new patient|first.*time|nueva cita|nova consulta|\b1\b/i.test(Body); - - if (isReschedule) { - const apptDatetime = await getAppointmentDatetime(patient.id); - if (apptDatetime) { - const foundMsgs: Record = { - English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`, - Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`, - Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`, - Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`, - Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`, - Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`, - "Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`, - }; - return reply(foundMsgs[language] ?? foundMsgs["English"]!, "asked_reschedule_datetime"); - } else { - const notFoundMsgs: Record = { - English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.", - Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.", - Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.", - Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。", - Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。", - Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.", - "Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.", - }; - return reply(notFoundMsgs[language] ?? notFoundMsgs["English"]!, "done"); - } - } - - // "new appointment" or ambiguous → ask new/existing patient - const newApptMsgs: Record = { - English: "No problem! To get started, are you an existing patient or a new patient?", - Spanish: "¡Sin problema! Para comenzar, ¿es usted un paciente existente o un paciente nuevo?", - Portuguese: "Sem problema! Para começar, você é um paciente existente ou um novo paciente?", - Mandarin: "没问题!请问您是现有患者还是新患者?", - Cantonese: "沒問題!請問您是現有病人還是新病人?", - Arabic: "لا مشكلة! للبدء، هل أنت مريض حالي أم مريض جديد؟", - "Haitian Creole": "Pa gen pwoblèm! Pou kòmanse, èske ou se yon pasyan egzistan oswa yon nouvo pasyan?", - }; - return reply(newApptMsgs[language] ?? newApptMsgs["English"]!, "asked_new_or_existing"); - } - - // ── Stage: asked_appointment_time → parse date, check office hours ─── - if (stage === "asked_appointment_time") { - const parsedDate = await parseDateOnlyFromMessage(Body, activeAi.key, activeAi.provider, activeAi.model); - if (!parsedDate) { - const msgs: Record = { - English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.", - Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.", - Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.", - Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。", - Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。", - Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.", - "Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.", - }; - return reply(msgs[language] ?? msgs["English"]!, "asked_appointment_time"); - } - const { date, dateLabel } = parsedDate; - const dayCheck = await isOfficeDayOpen(date, patient.userId); - if (!dayCheck.open) { - const msgs: Record = { - English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`, - Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`, - Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`, - Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`, - Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`, - Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`, - "Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`, - }; - return reply(msgs[language] ?? msgs["English"]!, "asked_appointment_time"); - } - // Day is open — save pending date and ask for preferred time - setPendingReschedule(patient.userId, patient.id, { newDate: date, dayLabel: dateLabel }); - const askTimeMsgs: Record = { - English: `What time do you prefer on ${dateLabel}?`, - Spanish: `¿A qué hora prefiere el ${dateLabel}?`, - Portuguese: `Que horário você prefere em ${dateLabel}?`, - Mandarin: `您希望在 ${dateLabel} 几点?`, - Cantonese: `您希望在 ${dateLabel} 幾點?`, - Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`, - "Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`, - }; - return reply(askTimeMsgs[language] ?? askTimeMsgs["English"]!, "asked_new_appt_time_for_date"); - } - - // ── Stage: asked_new_appt_time_for_date → parse time, check hours ──── - if (stage === "asked_new_appt_time_for_date") { - const pending = getPendingReschedule(patient.userId, patient.id); - if (!pending) { - return reply("I lost track of the date. What day would you prefer?", "asked_appointment_time"); - } - const startTime = await parseTime(Body, activeAi.key, activeAi.provider, activeAi.model); - if (!startTime) { - const msgs: Record = { - English: `I didn't catch the time. What time do you prefer on ${pending.dayLabel}? For example: '10am' or '2pm'.`, - Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am' o '2pm'.`, - Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h' ou '14h'.`, - Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点?例如:上午10点或下午2点。`, - Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點?例如:上午10點或下午2點。`, - Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`, - "Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am oswa 2pm.`, - }; - return reply(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date"); - } - const withinHours = await isWithinOfficeHours(pending.newDate, startTime, patient.userId); - if (!withinHours) { - const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, patient.userId); - const timeLbl = timeLabel(startTime); - const msgs: Record = { - English: hoursDisplay - ? `Our office is not available at ${timeLbl} on ${pending.dayLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?` - : `Our office is not available at ${timeLbl} on ${pending.dayLabel}. What other time do you prefer?`, - Spanish: hoursDisplay - ? `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?` - : `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. ¿Qué otro horario prefiere?`, - Portuguese: hoursDisplay - ? `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?` - : `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Que outro horário você prefere?`, - Mandarin: hoursDisplay - ? `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?` - : `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。您希望改什么时间?`, - Cantonese: hoursDisplay - ? `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?` - : `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。您希望改什麼時間?`, - Arabic: hoursDisplay - ? `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟` - : `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ما وقت آخر تفضله؟`, - "Haitian Creole": hoursDisplay - ? `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?` - : `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Ki lòt lè ou prefere?`, - }; - return reply(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date"); - } - clearPendingReschedule(patient.userId, patient.id); - const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`; - - // Calculate end time (60-minute default slot) - const [nh, nm] = startTime.split(":").map(Number); - const endTotal = nh! * 60 + nm! + 60; - const endTime = `${String(Math.floor(endTotal / 60)).padStart(2, "0")}:${String(endTotal % 60).padStart(2, "0")}`; - - // Look up patient name + find first staff for this office - const [patientRecord, firstStaff] = await Promise.all([ - db.patient.findUnique({ - where: { id: patient.id }, - select: { firstName: true, lastName: true }, - }), - db.staff.findFirst({ - where: { userId: patient.userId }, - orderBy: { id: "asc" }, - }), - ]); - - // Create the appointment if a staff member exists - if (firstStaff) { - try { - await storage.createAppointment({ - patientId: patient.id, - userId: patient.userId, - staffId: firstStaff.id, - title: `AI Scheduled - ${(patientRecord?.firstName ?? "") + " " + (patientRecord?.lastName ?? "")}`.trim(), - date: pending.newDate, - startTime, - endTime, - type: "checkup", - status: "scheduled", - movedByAi: true, - } as any); - } catch { /* silent — message still sent, staff can create manually */ } - } - - const confirmMsgs: Record = { - English: `Thank you! Your preferred appointment at ${apptLabel} was scheduled. Our receptionist will confirm it with you shortly.`, - Spanish: `¡Gracias! Su cita preferida el ${apptLabel} fue programada. Nuestra recepcionista lo confirmará con usted en breve.`, - Portuguese: `Obrigado! Sua consulta preferida em ${apptLabel} foi agendada. Nossa recepcionista confirmará com você em breve.`, - Mandarin: `谢谢!您在 ${apptLabel} 的预约已安排。我们的前台将很快与您确认。`, - Cantonese: `多謝!您在 ${apptLabel} 的預約已安排。我們的接待員將很快與您確認。`, - Arabic: `شكراً! تم جدولة موعدك في ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيده.`, - "Haitian Creole": `Mèsi! Randevou ou a nan ${apptLabel} pwograme. Resepsyonis nou an pral konfime li ak ou byento.`, - }; - return reply(confirmMsgs[language] ?? confirmMsgs["English"]!, "done"); - } - - // ── Stage: new_patient_greeted + multi-step new patient stages ──────── - const newPatientStages: ConversationStage[] = [ - "new_patient_greeted", "asked_new_or_existing", - "asked_new_patient_insurance", "asked_insurance_type", - "asked_masshealth_check_consent", "asked_existing_insurance", - "asked_appointment_preference", - "asked_self_pay", "asked_other_insurance_after_inactive", - "collecting_contact_info", - ]; - if (newPatientStages.includes(stage)) { - const { reply: aiReply, nextStage } = await runNewPatientStep( - Body, stage, language, activeAi.key, chatTemplates.generalFallback, activeAi.provider, activeAi.model - ); - return reply(aiReply, nextStage); - } - - // ── Stage: done → closing thank-you reply ──────────────────────────── - // When the patient sends a thank-you / acknowledgement after the conversation - // is complete, reply warmly with their upcoming appointment time. - if (stage === "done") { - const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(Body); - if (isThanks) { - const apptDatetime = await getAppointmentDatetime(patient.id); - const CLOSING: Record = { - English: apptDatetime - ? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.` - : `Thank you for choosing our office! We look forward to seeing you soon.`, - Spanish: apptDatetime - ? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.` - : `¡Gracias por elegirnos! Le esperamos pronto.`, - Portuguese: apptDatetime - ? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.` - : `Obrigado por nos escolher! Aguardamos sua visita em breve.`, - Mandarin: apptDatetime - ? `感谢您选择我们!期待在 ${apptDatetime} 见到您。` - : `感谢您选择我们!期待很快见到您。`, - Cantonese: apptDatetime - ? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。` - : `感謝您選擇我們!期待很快見到您。`, - Arabic: apptDatetime - ? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.` - : `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`, - "Haitian Creole": apptDatetime - ? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.` - : `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`, - }; - const fallback = CLOSING[language] ?? CLOSING["English"]!; - if (apptDatetime) { - try { - const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model); - const res = await llm.invoke([ - { - role: "system", - content: `You are a friendly dental office AI assistant. The patient just said "${Body}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`, - }, - { role: "user", content: Body }, - ]); - const aiMsg = String(res.content).trim(); - if (aiMsg) return reply(aiMsg, "done"); - } catch { /* fall through to fallback */ } - } - return reply(fallback, "done"); - } + return res.send(twimlMessages(...routed.segments)); } // ── Stage: initial / done (patient texts in fresh) ─────────────────── @@ -1237,27 +643,43 @@ const MAX_AI_CALL_TURNS = 12; interface AiCallSession { patientId: number; userId: number; + phone: string | null; + language: string; + officeName: string; + chatTemplates: Record; history: { role: "assistant" | "user"; text: string }[]; turns: number; } const aiCallSessions = new Map(); -function aiVoiceTwiml(sayText: string, gatherActionUrl: string): string { +interface VoiceLocale { gatherLang: string; sayVoice: string; } + +/** English/Spanish only for now — Twilio's native voice/recognition coverage + * for the app's other supported languages (Mandarin, Cantonese, Arabic, + * Haitian Creole) is inconsistent, so those fall back to English on calls. */ +function voiceLocaleFor(language: string): VoiceLocale { + if (language === "Spanish") return { gatherLang: "es-US", sayVoice: "Polly.Lupe" }; + return { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; +} + +const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; + +function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string { // action is always set, so Twilio posts back to it on both speech and // silence timeout — nothing after would ever run. return ` - - ${escapeXml(sayText)} + + ${escapeXml(sayText)} `; } -function aiVoiceHangup(sayText: string): string { +function aiVoiceHangup(sayText: string, locale: VoiceLocale = ENGLISH_LOCALE): string { return ` - ${escapeXml(sayText)} + ${escapeXml(sayText)} `; } @@ -1266,6 +688,9 @@ function aiVoiceHangup(sayText: string): string { // Entry point + conversation loop for outbound AI-driven calls placed via // POST /api/twilio/make-ai-call. Twilio hits this once when the call connects // (no CallSid session yet) and again after every with SpeechResult. +// Each spoken turn is routed through the same routePatientTurn() logic the +// SMS webhook uses, so calls follow identical rules/guardrails, in the +// patient's preferred language (English/Spanish supported for voice). router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise => { res.set("Content-Type", "text/xml"); try { @@ -1279,26 +704,35 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise= MAX_AI_CALL_TURNS) { aiCallSessions.delete(CallSid); - return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.")); + return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.", locale)); } const aiSettings = await storage.getAiSettings(session.userId); const activeAi = resolveAiProvider(aiSettings ?? {}); if (!activeAi) { aiCallSessions.delete(CallSid); - return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.")); + return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.", locale)); } - const patientRow = await db.patient.findUnique({ where: { id: session.patientId }, select: { firstName: true } }); - const ctx = (session as any).ctx || {}; - const reply = await runVoiceAssistantTurn( - session.history, - { firstName: patientRow?.firstName || "", ...ctx }, - activeAi.key, activeAi.provider, activeAi.model - ); + const stage = await getStage(session.userId, session.patientId); + const routed = await routePatientTurn({ + patient: { + id: session.patientId, userId: session.userId, + phone: session.phone, preferredLanguage: session.language, + }, + stage, message: SpeechResult.trim(), activeAi, + chatTemplates: session.chatTemplates, officeName: session.officeName, + }); + + const reply = routed.segments.join(" ").trim() || + "Sorry, I didn't quite catch that — could you say that again?"; session.history.push({ role: "assistant", text: reply }); await storage.createCommunication({ @@ -1358,13 +796,13 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise) || {}; const updated: Record = { ...existing }; @@ -90,7 +89,37 @@ export const twilioStorage = { if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback; if (templates.rescheduleGreeting !== undefined) updated["_ai_chat_reschedule_greeting"] = templates.rescheduleGreeting; if (templates.reminderSms !== undefined) updated["_ai_chat_reminder_sms"] = templates.reminderSms; - if (templates.callTemplate !== undefined) updated["_ai_chat_call_template"] = templates.callTemplate; + return db.twilioSettings.upsert({ + where: { userId }, + update: { templates: updated }, + create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated }, + }); + }, + + // ── Call Templates — wording spoken during live AI phone calls, kept + // separate from the SMS wording above (routePatientTurn() consumes + // whichever set the caller passes in). ──────────────────────────────────── + + async getAiCallTemplates(userId: number): Promise> { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const all = (settings?.templates as Record) || {}; + return { + greeting: all["_ai_call_greeting"] ?? all["_ai_chat_call_template"] ?? + "Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?", + reminderGreeting: all["_ai_call_reminder_greeting"] ?? "Sure, I can help with that.", + newPatientGreeting: all["_ai_call_new_patient_greeting"] ?? "No problem, let's get you set up.", + generalFallback: all["_ai_call_general_fallback"] ?? "Sure, how can I help?", + }; + }, + + async saveAiCallTemplates(userId: number, templates: { greeting?: string; 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.greeting !== undefined) updated["_ai_call_greeting"] = templates.greeting; + if (templates.reminderGreeting !== undefined) updated["_ai_call_reminder_greeting"] = templates.reminderGreeting; + if (templates.newPatientGreeting !== undefined) updated["_ai_call_new_patient_greeting"] = templates.newPatientGreeting; + if (templates.generalFallback !== undefined) updated["_ai_call_general_fallback"] = templates.generalFallback; return db.twilioSettings.upsert({ where: { userId }, update: { templates: updated }, diff --git a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx index 47dd9a0d..b5bdd990 100644 --- a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx +++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx @@ -18,7 +18,13 @@ type AiChatTemplates = { reminderGreeting: string; newPatientGreeting: string; generalFallback: string; - callTemplate: string; +}; + +type AiCallTemplates = { + greeting: string; + reminderGreeting: string; + newPatientGreeting: string; + generalFallback: string; }; type OfficeContact = { @@ -37,8 +43,14 @@ const DEFAULTS = { "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: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?", - callTemplate: +}; + +const CALL_DEFAULTS = { + greeting: "Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?", + reminderGreeting: "Sure, I can help with that.", + newPatientGreeting: "No problem, let's get you set up.", + generalFallback: "Sure, how can I help?", }; const DEFAULT_SMS_TEMPLATES = [ @@ -1012,9 +1024,14 @@ export function AiChatSettingsCard() { const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting); const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting); const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback); - const [callTemplate, setCallTemplate] = useState(DEFAULTS.callTemplate); const initialized = useRef(false); + const [callGreeting, setCallGreeting] = useState(CALL_DEFAULTS.greeting); + const [callReminderGreeting, setCallReminderGreeting] = useState(CALL_DEFAULTS.reminderGreeting); + const [callNewPatientGreeting, setCallNewPatientGreeting] = useState(CALL_DEFAULTS.newPatientGreeting); + const [callGeneralFallback, setCallGeneralFallback] = useState(CALL_DEFAULTS.generalFallback); + const callInitialized = useRef(false); + const [openPhoneReply, setOpenPhoneReply] = useState(false); const { data: advancedSettings } = useQuery<{ openPhoneReply: boolean }>({ @@ -1091,10 +1108,31 @@ export function AiChatSettingsCard() { setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting); setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting); setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback); - setCallTemplate(templates.callTemplate || DEFAULTS.callTemplate); } }, [templates]); + const { data: callTemplatesData, isLoading: callTemplatesLoading } = useQuery({ + queryKey: ["/api/ai/call-templates"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/ai/call-templates"); + if (!res.ok) throw new Error("Failed to load call templates"); + return res.json(); + }, + staleTime: Infinity, + refetchOnWindowFocus: false, + }); + + // Seed AI call templates + useEffect(() => { + if (callTemplatesData && !callInitialized.current) { + callInitialized.current = true; + setCallGreeting(callTemplatesData.greeting || CALL_DEFAULTS.greeting); + setCallReminderGreeting(callTemplatesData.reminderGreeting || CALL_DEFAULTS.reminderGreeting); + setCallNewPatientGreeting(callTemplatesData.newPatientGreeting || CALL_DEFAULTS.newPatientGreeting); + setCallGeneralFallback(callTemplatesData.generalFallback || CALL_DEFAULTS.generalFallback); + } + }, [callTemplatesData]); + // Seed SMS template list — fall back to the saved reminderSms if list is empty useEffect(() => { if (smsTemplateListData && !smsInitialized.current) { @@ -1125,6 +1163,24 @@ export function AiChatSettingsCard() { }, }); + const saveCallMutation = useMutation({ + mutationFn: async (data: AiCallTemplates) => { + const res = await apiRequest("PUT", "/api/ai/call-templates", data); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message || "Failed to save call templates"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/ai/call-templates"] }); + toast({ title: "Templates saved", description: "AI call templates have been updated." }); + }, + onError: (err: any) => { + toast({ title: "Error", description: err?.message, variant: "destructive" }); + }, + }); + const saveSmsListMutation = useMutation({ mutationFn: async (list: SmsTemplate[]) => { const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list); @@ -1149,7 +1205,16 @@ export function AiChatSettingsCard() { reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting, newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting, generalFallback: generalFallback.trim() || DEFAULTS.generalFallback, - callTemplate: callTemplate.trim() || DEFAULTS.callTemplate, + }); + }; + + const handleCallSubmit = (e: React.FormEvent) => { + e.preventDefault(); + saveCallMutation.mutate({ + greeting: callGreeting.trim() || CALL_DEFAULTS.greeting, + reminderGreeting: callReminderGreeting.trim() || CALL_DEFAULTS.reminderGreeting, + newPatientGreeting: callNewPatientGreeting.trim() || CALL_DEFAULTS.newPatientGreeting, + generalFallback: callGeneralFallback.trim() || CALL_DEFAULTS.generalFallback, }); }; @@ -1197,14 +1262,44 @@ export function AiChatSettingsCard() { onChange: setGeneralFallback, placeholder: DEFAULTS.generalFallback, }, + ]; + + const callTemplateFields = [ { - key: "call", + key: "callGreeting", icon: , - label: "Call Template", - description: "Lisa's opening line when placing an AI Call to a patient. Separate from the SMS chat templates above — use {firstName} and {officeName} as placeholders.", - value: callTemplate, - onChange: setCallTemplate, - placeholder: DEFAULTS.callTemplate, + label: "Call Opening Greeting", + description: "Lisa's opening line when placing an AI Call to a patient — use {firstName} and {officeName} as placeholders.", + value: callGreeting, + onChange: setCallGreeting, + placeholder: CALL_DEFAULTS.greeting, + }, + { + key: "callReminder", + icon: , + label: "Reminder Call Reply", + description: "Spoken after the call's opening greeting when the AI is following up on an appointment reminder — keep it short, it's read aloud, not texted.", + value: callReminderGreeting, + onChange: setCallReminderGreeting, + placeholder: CALL_DEFAULTS.reminderGreeting, + }, + { + key: "callNewPatient", + icon: , + label: "New Patient Call Reply", + description: "Spoken when the AI is walking a new patient through scheduling on a live call.", + value: callNewPatientGreeting, + onChange: setCallNewPatientGreeting, + placeholder: CALL_DEFAULTS.newPatientGreeting, + }, + { + key: "callGeneral", + icon: , + label: "General Call Fallback", + description: "Spoken when the AI cannot determine the context of what the patient said on a call.", + value: callGeneralFallback, + onChange: setCallGeneralFallback, + placeholder: CALL_DEFAULTS.generalFallback, }, ]; @@ -1408,7 +1503,85 @@ export function AiChatSettingsCard() { setReminderGreeting(DEFAULTS.reminderGreeting); setNewPatientGreeting(DEFAULTS.newPatientGreeting); setGeneralFallback(DEFAULTS.generalFallback); - setCallTemplate(DEFAULTS.callTemplate); + }} + > + Reset to defaults + + + + )} + + + + {/* ── Section 1b: Call Templates ───────────────────────────── */} + + +
+ +

Call Templates

+
+

+ Customize what Lisa says on live AI phone calls — kept separate from the SMS wording + above since spoken conversation reads differently than a text message. Use{" "} + {"{officeName}"}{" "} + and{" "} + {"{firstName}"}{" "} + as placeholders. +

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

Loading templates...

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

{f.description}

+