From cef928be97f940673f93579ae10e1ee804d051c7 Mon Sep 17 00:00:00 2001 From: ff Date: Sun, 2 Aug 2026 23:13:09 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20AI=20recall=20calling=20=E2=80=94?= =?UTF-8?q?=20books=20existing=20patients=20for=20routine=20cleanings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new recall conversation flow (recall-graph.ts) triggered from the Patient Connection "AI Call" button: offers an existing patient a routine exam & cleaning, classifies their yes/no reply, then books directly onto the schedule (Column A, 30-minute slots, real conflict checking against office hours and existing appointments) instead of only capturing a preference for staff to follow up on. Also adds a "Voice call" label in the message thread to distinguish call transcripts from SMS, and updates the Call Templates settings UI with an editable recall greeting. Co-Authored-By: Claude Sonnet 5 --- apps/Backend/src/ai/aiHandoffStore.ts | 9 + .../src/ai/patient-conversation-router.ts | 41 ++ apps/Backend/src/ai/recall-graph.ts | 450 ++++++++++++++++++ apps/Backend/src/routes/ai-settings.ts | 4 +- apps/Backend/src/routes/twilio-webhooks.ts | 15 +- apps/Backend/src/routes/twilio.ts | 5 +- apps/Backend/src/storage/twilio-storage.ts | 5 +- .../patient-connection/message-thread.tsx | 14 +- .../settings/ai-chat-settings-card.tsx | 82 ++-- .../src/pages/patient-connection-page.tsx | 2 +- 10 files changed, 588 insertions(+), 39 deletions(-) create mode 100644 apps/Backend/src/ai/recall-graph.ts diff --git a/apps/Backend/src/ai/aiHandoffStore.ts b/apps/Backend/src/ai/aiHandoffStore.ts index 429b382d..ac4d865b 100644 --- a/apps/Backend/src/ai/aiHandoffStore.ts +++ b/apps/Backend/src/ai/aiHandoffStore.ts @@ -3,6 +3,9 @@ import { prisma as db } from "@repo/db/client"; export type ConversationStage = | "initial" | "reminder_initial" + | "recall_initial" + | "recall_asked_datetime" + | "recall_asked_time_for_date" | "greeted" | "done" | "new_patient_greeted" @@ -81,6 +84,12 @@ export async function startRescheduleConversation(userId: number, patientId: num await setStage(userId, patientId, "asked_reschedule_confirm"); } +// Recall: office proactively calls/texts an existing patient to offer a +// routine exam & cleaning (typically covered at no cost by insurance). +export async function startRecallConversation(userId: number, patientId: number): Promise { + await setStage(userId, patientId, "recall_initial"); +} + // ── Pending reschedule (in-memory — seconds-lived within a single exchange) ─── interface PendingReschedule { diff --git a/apps/Backend/src/ai/patient-conversation-router.ts b/apps/Backend/src/ai/patient-conversation-router.ts index 2ff64d5e..05efec51 100644 --- a/apps/Backend/src/ai/patient-conversation-router.ts +++ b/apps/Backend/src/ai/patient-conversation-router.ts @@ -2,6 +2,7 @@ import twilio from "twilio"; import { prisma as db } from "@repo/db/client"; import { storage } from "../storage"; import { runReminderGraph } from "./reminder-graph"; +import { runRecallGraph, runRecallBookingStep } from "./recall-graph"; import { runNewPatientStep } from "./new-patient-graph"; import { runRescheduleStep, @@ -273,6 +274,46 @@ export async function routePatientTurn(params: { return finish(patient.userId, patient.id, [introText], "greeted"); } + // ── Stage: recall_initial → office proactively offered an existing patient + // a routine exam & cleaning (usually covered at no cost by insurance); the + // pitch itself was already spoken/sent as the opening greeting, so this + // just classifies the patient's yes/no. On yes, it books directly onto the + // schedule (Column A, 30-minute slots) via runRecallBookingStep — unlike + // reschedule, a recall patient has no existing appointment to move, so this + // is a real create-appointment path, not a shortcut into the reschedule flow. + if (stage === "recall_initial") { + const { reply: intentReply, intent } = await runRecallGraph( + message, activeAi.key, language, chatTemplates.generalFallback, activeAi.provider, activeAi.model + ); + + if (intentReply) { + if (intent === "yes") { + // Patient may have already given a date (or date + time) in this same + // reply ("Tuesday 1pm") — try to book right away instead of asking again. + const { reply: bookingReply, nextStage: bookingNextStage } = await runRecallBookingStep( + message, "recall_asked_datetime", language, patient.id, patient.userId, activeAi.key, activeAi.provider, activeAi.model + ); + return finish(patient.userId, patient.id, [bookingReply], bookingNextStage); + } + if (intent === "no") { + return finish(patient.userId, patient.id, [intentReply], "done"); + } + return finish(patient.userId, patient.id, [intentReply], "recall_initial"); + } + + return finish(patient.userId, patient.id, [chatTemplates.generalFallback || "Thank you for your message!"], "recall_initial"); + } + + // ── Recall booking flow — collecting/confirming the day & time, then + // creating the appointment (see recall-graph.ts: runRecallBookingStep) ── + const recallBookingStages: ConversationStage[] = ["recall_asked_datetime", "recall_asked_time_for_date"]; + if (recallBookingStages.includes(stage)) { + const { reply: aiReply, nextStage } = await runRecallBookingStep( + message, stage, language, patient.id, patient.userId, activeAi.key, activeAi.provider, activeAi.model + ); + return finish(patient.userId, patient.id, [aiReply], nextStage); + } + // ── Stage: greeted → classify yes/no for appointment reminder ────────── if (stage === "greeted") { const apptDatetime = await getAppointmentDatetime(patient.id); diff --git a/apps/Backend/src/ai/recall-graph.ts b/apps/Backend/src/ai/recall-graph.ts new file mode 100644 index 00000000..17a2240c --- /dev/null +++ b/apps/Backend/src/ai/recall-graph.ts @@ -0,0 +1,450 @@ +import { StateGraph, END, START, Annotation } from "@langchain/langgraph"; +import { getLlm, type AiProvider } from "./llm-factory"; +import { prisma as db } from "@repo/db/client"; +import { storage } from "../storage"; +import { + type ConversationStage, + setPendingReschedule, getPendingReschedule, clearPendingReschedule, +} from "./aiHandoffStore"; +import { + parseDateOnlyFromMessage, parseTime, isOfficeDayOpen, isWithinOfficeHours, + getOfficeHoursDisplay, timeLabel, +} from "./reschedule-graph"; + +const GraphState = Annotation.Root({ + message: Annotation(), + intent: Annotation(), + reply: Annotation(), + language: Annotation(), + generalFallback: Annotation(), +}); + +type GraphStateType = typeof GraphState.State; + +// ── Intent classifier — multilingual yes/no keywords (shared shape with reminder-graph) ── + +function classifyNode(state: GraphStateType) { + const text = state.message.toLowerCase().trim(); + + const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|sounds good|i'?d like (that|to)|let'?s do it|book it|schedule it|i want (an? )?appointment|i want to (come in|schedule|book)|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|de acuerdo|seguro|sim|com certeza|好的|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/; + const noPatterns = /\b(no|nope|not interested|not right now|no thanks|can't|cannot|won't|not now|not available|busy|sorry|unable|no puedo|no gracias|ahora no|não posso|agora não|não|占线|无法|不用了|不需要|لا|لا أستطيع|pa kapab|pa ka)\b/; + + // A specific day/date/time, or an appointment-request phrase, is itself a + // strong signal the patient wants to book — real replies rarely say a bare + // "yes", they jump straight to "Tuesday at 1pm" or "I want an appointment". + const mentionsDateOrTime = + /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(text) || + /\b\d{1,2}[\/\-]\d{1,2}\b/.test(text) || + /\b\d{1,2}(:\d{2})?\s*(am|pm|a\.m\.|p\.m\.)\b/i.test(text); + const mentionsAppointment = /\b(appointment|schedule|book|come in|check.?up|cleaning)\b/i.test(text); + + if (noPatterns.test(text) && !mentionsDateOrTime) return { intent: "no" }; + if (yesPatterns.test(text) || mentionsDateOrTime || mentionsAppointment) return { intent: "yes" }; + return { intent: "other" }; +} + +function routeByIntent(state: GraphStateType): string { + if (state.intent === "yes") return "wantsBooking"; + if (state.intent === "no") return "declines"; + return "other"; +} + +// ── Fallbacks ────────────────────────────────────────────────────────────── + +const WANTS_BOOKING_FALLBACKS: Record = { + English: "Great! What day works best for you?", + Spanish: "¡Genial! ¿Qué día le viene mejor?", + Portuguese: "Ótimo! Qual dia é melhor para você?", + Mandarin: "太好了!您哪天方便?", + Cantonese: "太好喇!您邊日方便?", + Arabic: "رائع! ما هو اليوم الأنسب لك؟", + "Haitian Creole": "Bon! Ki jou ki pi bon pou ou?", +}; + +const DECLINES_FALLBACKS: Record = { + English: "No problem at all! Feel free to reach out whenever you're ready. Have a great day!", + Spanish: "¡No hay problema! No dude en contactarnos cuando esté listo. ¡Que tenga un buen día!", + Portuguese: "Sem problemas! Entre em contato sempre que estiver pronto. Tenha um ótimo dia!", + Mandarin: "没问题!您准备好了随时联系我们。祝您愉快!", + Cantonese: "冇問題!您準備好隨時聯絡我們。祝您愉快!", + Arabic: "لا مشكلة على الإطلاق! لا تتردد في التواصل معنا عندما تكون مستعداً. أتمنى لك يوماً سعيداً!", + "Haitian Creole": "Pa gen pwoblèm! Ou ka kontakte nou nenpòt lè ou pare. Pase yon bon jounen!", +}; + +const GENERAL_FALLBACKS: Record = { + English: "Thank you for your message! Our office staff will be happy to assist you shortly.", + Spanish: "¡Gracias por su mensaje! El personal de nuestra oficina estará encantado de ayudarle en breve.", + Portuguese: "Obrigado pela sua mensagem! Nossa equipe terá prazer em ajudá-lo em breve.", + Mandarin: "感谢您的留言!我们的办公室工作人员将很快为您提供帮助。", + Cantonese: "感謝您的留言!我們的辦公室工作人員將很快為您提供幫助。", + Arabic: "شكراً على رسالتك! سيسعد فريق مكتبنا بمساعدتك قريباً.", + "Haitian Creole": "Mèsi pou mesaj ou! Ekip biwo nou an pral kontan ede ou byento.", +}; + +// ── LangGraph nodes ─────────────────────────────────────────────────────────── + +async function wantsBookingNode(state: GraphStateType, config: any) { + const apiKey: string | undefined = config?.configurable?.apiKey; + const lang = state.language || "English"; + const fallback = WANTS_BOOKING_FALLBACKS[lang] ?? WANTS_BOOKING_FALLBACKS["English"]!; + + if (!apiKey) return { reply: fallback }; + + try { + const provider: AiProvider = config?.configurable?.provider ?? "google"; + const model: string | undefined = config?.configurable?.model; + const llm = getLlm(provider, apiKey, model); + const response = await llm.invoke([ + { + role: "system", + content: + `You are a friendly dental office assistant. The patient agreed to come in for a routine exam and cleaning. Write a short, warm reply (1-2 sentences max) and ask what day works best for them. Do NOT introduce yourself or say your name — the introduction has already been sent in a separate message. You MUST reply in ${lang}. No formatting, no extra text.`, + }, + { role: "user", content: `Patient replied: "${state.message}"` }, + ]); + return { reply: String(response.content) || fallback }; + } catch { + return { reply: fallback }; + } +} + +async function declinesNode(state: GraphStateType, config: any) { + const apiKey: string | undefined = config?.configurable?.apiKey; + const lang = state.language || "English"; + const fallback = DECLINES_FALLBACKS[lang] ?? DECLINES_FALLBACKS["English"]!; + + if (!apiKey) return { reply: fallback }; + + try { + const provider: AiProvider = config?.configurable?.provider ?? "google"; + const model: string | undefined = config?.configurable?.model; + const llm = getLlm(provider, apiKey, model); + const response = await llm.invoke([ + { + role: "system", + content: + `You are a friendly dental office assistant. The patient does not want to schedule a checkup right now. Write a short, polite reply (1-2 sentences max) letting them know that's no problem and they can reach out whenever they're ready. Do NOT introduce yourself or say your name. You MUST reply in ${lang}. No formatting, no extra text.`, + }, + { role: "user", content: `Patient replied: "${state.message}"` }, + ]); + return { reply: String(response.content) || fallback }; + } catch { + return { reply: fallback }; + } +} + +async function otherNode(state: GraphStateType, config: any) { + const apiKey: string | undefined = config?.configurable?.apiKey; + const lang = state.language || "English"; + const fallback = state.generalFallback || (GENERAL_FALLBACKS[lang] ?? GENERAL_FALLBACKS["English"]!); + + if (!apiKey) return { reply: fallback }; + try { + const provider: AiProvider = config?.configurable?.provider ?? "google"; + const model: string | undefined = config?.configurable?.model; + const llm = getLlm(provider, apiKey, model); + const response = await llm.invoke([ + { + role: "system", + content: `You are a friendly dental office AI assistant. Respond helpfully to the patient's message in ${lang}. Keep it to 1-2 sentences, no formatting. For non-dental questions, let them know our office staff can assist.`, + }, + { role: "user", content: `Patient said: "${state.message}"` }, + ]); + return { reply: String(response.content) || fallback }; + } catch { + return { reply: fallback }; + } +} + +// ── Graph ───────────────────────────────────────────────────────────────────── + +const graph = new StateGraph(GraphState) + .addNode("classify", classifyNode) + .addNode("wantsBooking", wantsBookingNode) + .addNode("declines", declinesNode) + .addNode("other", otherNode) + .addEdge(START, "classify") + .addConditionalEdges("classify", routeByIntent, { + wantsBooking: "wantsBooking", + declines: "declines", + other: "other", + }) + .addEdge("wantsBooking", END) + .addEdge("declines", END) + .addEdge("other", END) + .compile(); + +export async function runRecallGraph( + patientMessage: string, + apiKey: string, + language = "English", + generalFallback = "", + provider: AiProvider = "google", + model?: string +): Promise<{ reply: string | null; intent: string | null }> { + const result = await graph.invoke( + { message: patientMessage, intent: "", reply: "", language, generalFallback }, + { configurable: { apiKey, provider, model } } + ); + return { + reply: result.reply || null, + intent: result.intent || null, + }; +} + +// ── Recall booking — actually reserves a slot, unlike the reschedule flow ── +// (which only MOVES an existing appointment). A recall patient has none, so +// this creates a new one directly on "Column A" (the office's first staff +// column, matching the convention already used in internal-chat-workflow.ts), +// in fixed 30-minute slots. + +const RECALL_SLOT_MINUTES = 30; + +function toMinutes(t: string): number { + const [h, m] = t.split(":").map(Number); + return (h ?? 0) * 60 + (m ?? 0); +} +function fromMinutes(m: number): string { + return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; +} + +/** "Column A" = the office's first staff record by id, per the same convention + * used for AI-booked appointments elsewhere (see internal-chat-workflow.ts). */ +async function getColumnAStaffId(userId: number): Promise { + const staff = await db.staff.findFirst({ where: { userId }, orderBy: { id: "asc" } }); + return staff?.id ?? null; +} + +/** + * Finds a free RECALL_SLOT_MINUTES slot for `staffId` on `date`. Prefers + * `requestedStartTime` if it's free; otherwise falls back to the earliest + * open slot that day. Returns null if the whole day is fully booked. + */ +async function findRecallSlot( + userId: number, staffId: number, date: Date, requestedStartTime?: string | null, +): Promise<{ time: string; matchedRequest: boolean } | null> { + const officeHours = await storage.getOfficeHours(userId); + const data = (officeHours as any)?.data; + const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"]; + const dayName = dayNames[date.getUTCDay()]!; + + // Union of doctors' + hygienists' windows that day — matches the "open if + // within either group's hours" tolerance isWithinOfficeHours() already uses. + const windows: [number, number][] = []; + for (const group of [data?.doctors, data?.hygienists]) { + const slot = group?.[dayName]; + if (slot?.enabled) { + windows.push([toMinutes(slot.amStart), toMinutes(slot.amEnd)]); + windows.push([toMinutes(slot.pmStart), toMinutes(slot.pmEnd)]); + } + } + if (windows.length === 0) { + windows.push([toMinutes("09:00"), toMinutes("12:00")], [toMinutes("13:00"), toMinutes("17:00")]); + } + + const allSlots = new Set(); + for (const [start, end] of windows) { + for (let t = start; t + RECALL_SLOT_MINUTES <= end; t += RECALL_SLOT_MINUTES) allSlots.add(t); + } + + const dateStr = date.toISOString().split("T")[0]!; + const existing = await storage.getAppointmentsByDateForUser(dateStr, userId); + const booked = existing + .filter((a: any) => a.staffId === staffId && a.status !== "cancelled") + .map((a: any) => ({ start: toMinutes(a.startTime), end: toMinutes(a.endTime) })); + + const isFree = (slotStart: number) => { + const slotEnd = slotStart + RECALL_SLOT_MINUTES; + return !booked.some((b) => slotStart < b.end && slotEnd > b.start); + }; + + if (requestedStartTime) { + const reqStart = toMinutes(requestedStartTime); + if (allSlots.has(reqStart) && isFree(reqStart)) return { time: requestedStartTime, matchedRequest: true }; + } + + const sorted = Array.from(allSlots).sort((a, b) => a - b); + const fallback = sorted.find(isFree); + return fallback === undefined ? null : { time: fromMinutes(fallback), matchedRequest: false }; +} + +const DAY_FULL_FALLBACKS: Record string> = { + English: (d) => `I'm sorry, ${d} is fully booked. Could you please choose another day?`, + Spanish: (d) => `Lo sentimos, el ${d} ya está completo. ¿Podría elegir otro día?`, + Portuguese: (d) => `Sinto muito, ${d} está totalmente reservado. Poderia escolher outro dia?`, + Mandarin: (d) => `很抱歉,${d} 的预约已满。您能选择其他日期吗?`, + Cantonese: (d) => `好抱歉,${d} 已經約滿。您可以選擇其他日子嗎?`, + Arabic: (d) => `عذراً، ${d} محجوز بالكامل. هل يمكنك اختيار يوم آخر؟`, + "Haitian Creole": (d) => `Padon, ${d} konplè. Èske ou ka chwazi yon lòt jou?`, +}; + +const NO_STAFF_FALLBACK: Record = { + English: "Thanks! Our office staff will reach out shortly to confirm a time that works.", + Spanish: "¡Gracias! Nuestro personal se pondrá en contacto en breve para confirmar un horario.", + Portuguese: "Obrigado! Nossa equipe entrará em contato em breve para confirmar um horário.", + Mandarin: "谢谢!我们的工作人员将很快与您联系以确认时间。", + Cantonese: "多謝!我們的職員好快會聯絡您確認時間。", + Arabic: "شكراً! سيتواصل معك فريقنا قريباً لتأكيد الموعد.", + "Haitian Creole": "Mèsi! Ekip biwo nou an pral kontakte ou byento pou konfime yon lè.", +}; + +const BOOKED_EXACT_FALLBACKS: Record string> = { + English: (d, t) => `You're all set for ${d} at ${t}! We look forward to seeing you.`, + Spanish: (d, t) => `¡Todo listo para el ${d} a las ${t}! Le esperamos.`, + Portuguese: (d, t) => `Tudo pronto para ${d} às ${t}! Aguardamos sua visita.`, + Mandarin: (d, t) => `已为您安排在 ${d} ${t}!期待您的光临。`, + Cantonese: (d, t) => `已幫您安排喺 ${d} ${t}!期待見到您。`, + Arabic: (d, t) => `تم الحجز في ${d} الساعة ${t}! نتطلع لرؤيتك.`, + "Haitian Creole": (d, t) => `Ou pare pou ${d} a ${t}! N'ap tann ou.`, +}; + +const BOOKED_ALT_FALLBACKS: Record string> = { + English: (d, t) => `That exact time was just taken, so I booked you for ${t} on ${d} instead. We look forward to seeing you!`, + Spanish: (d, t) => `Esa hora ya no estaba disponible, así que le reservé a las ${t} el ${d}. ¡Le esperamos!`, + Portuguese: (d, t) => `Esse horário exato já foi reservado, então marquei ${t} em ${d}. Aguardamos sua visita!`, + Mandarin: (d, t) => `那个时间刚被预订了,我已为您安排在 ${d} ${t}。期待您的光临!`, + Cantonese: (d, t) => `嗰個時間啱啱被人訂咗,我已經幫您安排喺 ${d} ${t}。期待見到您!`, + Arabic: (d, t) => `تم حجز ذلك الوقت للتو، لذا حجزت لك موعداً في ${d} الساعة ${t}. نتطلع لرؤيتك!`, + "Haitian Creole": (d, t) => `Lè sa a fèk pran, kidonk mwen bwouke ou pou ${t} nan ${d}. N'ap tann ou!`, +}; + +const ASK_TIME_FALLBACKS: Record string> = { + English: (d) => `Great, what time on ${d} works for you?`, + Spanish: (d) => `Genial, ¿qué hora le viene bien el ${d}?`, + Portuguese: (d) => `Ótimo, que horário funciona para você em ${d}?`, + Mandarin: (d) => `太好了,您 ${d} 哪个时间方便?`, + Cantonese: (d) => `太好喇,您 ${d} 邊個時間方便?`, + Arabic: (d) => `رائع، ما الوقت المناسب لك في ${d}؟`, + "Haitian Creole": (d) => `Bon, ki lè nan ${d} ki bon pou ou?`, +}; + +const CLOSED_DAY_FALLBACKS: Record string> = { + English: (day) => `We're closed on ${day}s. What other day works for you?`, + Spanish: (day) => `Estamos cerrados los ${day}. ¿Qué otro día le viene bien?`, + Portuguese: (day) => `Estamos fechados aos ${day}. Que outro dia funciona para você?`, + Mandarin: (day) => `我们${day}休息。您方便其他哪天?`, + Cantonese: (day) => `我們${day}休息。您方便其他邊日?`, + Arabic: (day) => `نحن مغلقون أيام ${day}. ما يوم آخر يناسبك؟`, + "Haitian Creole": (day) => `Nou fèmen jou ${day}. Ki lòt jou ki bon pou ou?`, +}; + +const NO_DATE_FALLBACKS: Record = { + English: "Sorry, I didn't catch a day — what day would you like to come in?", + Spanish: "Disculpe, no entendí el día — ¿qué día le gustaría venir?", + Portuguese: "Desculpe, não entendi o dia — que dia você gostaria de vir?", + Mandarin: "抱歉,我没听清日期——您想哪天过来?", + Cantonese: "抱歉,我冇聽清楚日期——您想邊日嚟?", + Arabic: "عذراً، لم أفهم اليوم — ما اليوم الذي تود الحضور فيه؟", + "Haitian Creole": "Padon, mwen pa konprann jou a — ki jou ou ta renmen vini?", +}; + +/** Books the recall appointment (or reports the day is full) and returns the reply + next stage. */ +async function bookRecallSlot( + patientId: number, userId: number, date: Date, dateLabel: string, requestedStartTime: string | null, lang: string, +): Promise<{ reply: string; nextStage: ConversationStage }> { + const staffId = await getColumnAStaffId(userId); + if (staffId === null) { + return { reply: NO_STAFF_FALLBACK[lang] ?? NO_STAFF_FALLBACK["English"]!, nextStage: "done" }; + } + + const slot = await findRecallSlot(userId, staffId, date, requestedStartTime); + if (!slot) { + const build = DAY_FULL_FALLBACKS[lang] ?? DAY_FULL_FALLBACKS["English"]!; + return { reply: build(dateLabel), nextStage: "recall_asked_datetime" }; + } + + const endTime = fromMinutes(toMinutes(slot.time) + RECALL_SLOT_MINUTES); + await storage.createAppointment({ + patientId, userId, staffId, + title: "Routine Exam & Cleaning", + date, startTime: slot.time, endTime, + type: "recall", + status: "scheduled", + movedByAi: true, + } as any); + + const timeLbl = timeLabel(slot.time); + const build = slot.matchedRequest + ? (BOOKED_EXACT_FALLBACKS[lang] ?? BOOKED_EXACT_FALLBACKS["English"]!) + : (BOOKED_ALT_FALLBACKS[lang] ?? BOOKED_ALT_FALLBACKS["English"]!); + return { reply: build(dateLabel, timeLbl), nextStage: "done" }; +} + +/** + * Drives the recall_asked_datetime / recall_asked_time_for_date stages: + * parses a date (and optionally a time) out of the patient's reply, checks + * office hours, then books via bookRecallSlot() once both pieces are known. + */ +export async function runRecallBookingStep( + message: string, + stage: ConversationStage, + language: string, + patientId: number, + userId: number, + apiKey: string, + provider: AiProvider = "google", + model?: string +): Promise<{ reply: string; nextStage: ConversationStage }> { + const lang = language || "English"; + + // ── Waiting on a time only (date was already captured last turn) ───────── + if (stage === "recall_asked_time_for_date") { + const pending = getPendingReschedule(userId, patientId); + if (!pending) { + return { reply: NO_DATE_FALLBACKS[lang] ?? NO_DATE_FALLBACKS["English"]!, nextStage: "recall_asked_datetime" }; + } + const time = await parseTime(message, apiKey, provider, model); + if (!time) { + const build = ASK_TIME_FALLBACKS[lang] ?? ASK_TIME_FALLBACKS["English"]!; + return { reply: build(pending.dayLabel), nextStage: "recall_asked_time_for_date" }; + } + return await resolveDateTime(patientId, userId, pending.newDate, pending.dayLabel, time, lang); + } + + // ── recall_initial (yes, with a date already in the same message) or + // recall_asked_datetime — parse a date out of the message ──────────────── + const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model); + if (!parsedDate) { + return { reply: NO_DATE_FALLBACKS[lang] ?? NO_DATE_FALLBACKS["English"]!, nextStage: "recall_asked_datetime" }; + } + + const dayCheck = await isOfficeDayOpen(parsedDate.date, userId); + if (!dayCheck.open) { + const build = CLOSED_DAY_FALLBACKS[lang] ?? CLOSED_DAY_FALLBACKS["English"]!; + return { reply: build(dayCheck.displayDay), nextStage: "recall_asked_datetime" }; + } + + const time = await parseTime(message, apiKey, provider, model); + if (!time) { + setPendingReschedule(userId, patientId, { newDate: parsedDate.date, dayLabel: parsedDate.dateLabel }); + const build = ASK_TIME_FALLBACKS[lang] ?? ASK_TIME_FALLBACKS["English"]!; + return { reply: build(parsedDate.dateLabel), nextStage: "recall_asked_time_for_date" }; + } + + return await resolveDateTime(patientId, userId, parsedDate.date, parsedDate.dateLabel, time, lang); +} + +async function resolveDateTime( + patientId: number, userId: number, date: Date, dateLabel: string, time: string, lang: string, +): Promise<{ reply: string; nextStage: ConversationStage }> { + const withinHours = await isWithinOfficeHours(date, time, userId); + if (!withinHours) { + const hoursDisplay = await getOfficeHoursDisplay(date, userId); + setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel }); + const base: Record = { + English: `We're not available at that time on ${dateLabel}.`, + Spanish: `No estamos disponibles a esa hora el ${dateLabel}.`, + Portuguese: `Não estamos disponíveis nesse horário em ${dateLabel}.`, + Mandarin: `我们在 ${dateLabel} 的那个时间不营业。`, + Cantonese: `我們喺 ${dateLabel} 嗰個時間唔開放。`, + Arabic: `نحن غير متاحين في ذلك الوقت يوم ${dateLabel}.`, + "Haitian Creole": `Nou pa disponib nan lè sa a nan ${dateLabel}.`, + }; + const askTime = (ASK_TIME_FALLBACKS[lang] ?? ASK_TIME_FALLBACKS["English"]!)(dateLabel); + const hours = hoursDisplay ? ` ${hoursDisplay}.` : ""; + return { reply: `${base[lang] ?? base["English"]}${hours} ${askTime}`, nextStage: "recall_asked_time_for_date" }; + } + + clearPendingReschedule(userId, patientId); + return await bookRecallSlot(patientId, userId, date, dateLabel, time, lang); +} diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 8445d138..95f6db38 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -240,8 +240,8 @@ 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 { greeting, reminderGreeting, newPatientGreeting, generalFallback, recallGreeting } = req.body; + await storage.saveAiCallTemplates(userId, { greeting, reminderGreeting, newPatientGreeting, generalFallback, recallGreeting }); const updated = await storage.getAiCallTemplates(userId); return res.status(200).json(updated); } catch (err) { diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index a4b813e0..277ce5fd 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -19,7 +19,7 @@ import { } from "../ai/patient-conversation-router"; import { getHandoff, getAfterHoursHandoff, - getStage, setStage, resetConversation, + getStage, setStage, resetConversation, startRecallConversation, setPendingReschedule, getPendingReschedule, clearPendingReschedule, type ConversationStage, } from "../ai/aiHandoffStore"; @@ -696,6 +696,7 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise = const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const { to, patientId } = req.body; + const { to, patientId, purpose } = req.body; if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" }); const settings = await storage.getTwilioSettings(userId); @@ -149,7 +149,8 @@ router.post("/make-ai-call", async (req: Request, res: Response): Promise = } catch (err: any) { return res.status(500).json({ message: err.message }); } - const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}`; + const callPurpose = purpose === "recall" ? "recall" : "reminder"; + const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}&purpose=${callPurpose}`; const client = getTwilioClient(settings.accountSid, settings.authToken); const call = await client.calls.create({ diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts index 28e067f7..7205c7e6 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -109,10 +109,12 @@ export const twilioStorage = { 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?", + recallGreeting: all["_ai_call_recall_greeting"] ?? + "Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning, which most insurance plans cover at no cost. Would you like to schedule one?", }; }, - async saveAiCallTemplates(userId: number, templates: { greeting?: string; reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string }) { + async saveAiCallTemplates(userId: number, templates: { greeting?: string; reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; recallGreeting?: string }) { const settings = await db.twilioSettings.findUnique({ where: { userId } }); const existing = (settings?.templates as Record) || {}; const updated: Record = { ...existing }; @@ -120,6 +122,7 @@ export const twilioStorage = { 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; + if (templates.recallGreeting !== undefined) updated["_ai_call_recall_greeting"] = templates.recallGreeting; return db.twilioSettings.upsert({ where: { userId }, update: { templates: updated }, diff --git a/apps/Frontend/src/components/patient-connection/message-thread.tsx b/apps/Frontend/src/components/patient-connection/message-thread.tsx index fea971c8..594e70ed 100755 --- a/apps/Frontend/src/components/patient-connection/message-thread.tsx +++ b/apps/Frontend/src/components/patient-connection/message-thread.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; -import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX } from "lucide-react"; +import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX, Phone } 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"; @@ -534,6 +534,12 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea {patient.firstName[0]}{patient.lastName[0]}
+ {comm.channel === "voice" && ( +
+ + Voice call +
+ )}

{comm.body}

@@ -545,6 +551,12 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea )} {comm.direction === "outbound" && (
+ {comm.channel === "voice" && ( +
+ + Voice call +
+ )}

{comm.body}

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 b5bdd990..5f629795 100644 --- a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx +++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx @@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; -import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall } from "lucide-react"; +import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall, Stethoscope } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -25,6 +25,7 @@ type AiCallTemplates = { reminderGreeting: string; newPatientGreeting: string; generalFallback: string; + recallGreeting: string; }; type OfficeContact = { @@ -51,6 +52,8 @@ const CALL_DEFAULTS = { reminderGreeting: "Sure, I can help with that.", newPatientGreeting: "No problem, let's get you set up.", generalFallback: "Sure, how can I help?", + recallGreeting: + "Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning, which most insurance plans cover at no cost. Would you like to schedule one?", }; const DEFAULT_SMS_TEMPLATES = [ @@ -82,18 +85,19 @@ function LangGraphFlow() { const nW = 210; const nx = cx - nW / 2; // 245 - // ── Dual entry nodes ────────────────────────────────────────────────────── - const entryW = 192; - const lEntryCx = 155; - const rEntryCx = 545; - const e1y = 14, e1h = 56; + // ── Triple entry nodes ───────────────────────────────────────────────────── + const entryW = 150; + const lEntryCx = 130; + const mEntryCx = cx; // 350 + const rEntryCx = 570; + const e1y = 14, e1h = 70; - // Merge connector (horizontal line where both branches meet) - const mergeY = e1y + e1h + 14; // 84 + // Merge connector (horizontal line where all three entries meet) + const mergeY = e1y + e1h + 14; // 98 // ── Center sequence ─────────────────────────────────────────────────────── - const n2y = mergeY + 14, n2h = 52; // Patient replies y=98 - const n3y = n2y + n2h + 14, n3h = 84; // AI classifies y=164 + const n2y = mergeY + 14, n2h = 52; // Patient replies + const n3y = n2y + n2h + 14, n3h = 98; // AI classifies const forkHY = n3y + n3h + 22; // 270 @@ -103,7 +107,7 @@ function LangGraphFlow() { // ── YES branch ──────────────────────────────────────────────────────────── const yesW = 195; - const yes1y = forkHY + 50, yes1h = 88; // y=320 + const yes1y = forkHY + 50, yes1h = 102; // y=320 const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460 // ── NO / Reschedule branch ──────────────────────────────────────────────── @@ -128,7 +132,7 @@ function LangGraphFlow() { viewBox={`0 0 ${W} ${totalH}`} className="w-full max-w-2xl mx-auto" role="img" - aria-label="LangGraph Reminder and Reschedule conversation flow" + aria-label="LangGraph Reminder, Recall, and Reschedule conversation flow" > - {/* ══ DUAL ENTRY NODES ════════════════════════════════════════════ */} + {/* ══ TRIPLE ENTRY NODES ════════════════════════════════════════════ */} {/* Left: Reminder SMS */} - Office sends Reminder SMS - Staff triggers batch send + Office sends Reminder + SMS or Call + Staff triggers batch send + + {/* Middle: Recall call/SMS for an existing patient */} + + Office call or SMS for + an existing patient + Recall: free exam & cleaning {/* Right: Reschedule by Office SMS */} - Office sends Reschedule SMS - "Reschedule by Office" template + Office sends Reschedule + SMS + "Reschedule by Office" template {/* Converging lines → horizontal merge → Patient replies */} + @@ -173,10 +186,11 @@ function LangGraphFlow() { {/* N3: AI classifies */} - Google AI classifies YES / NO - Reminder: intro (MSG 1) + reply (MSG 2) - Reschedule: reply sent directly - Date in reply → AI skips to Day Check + Google AI classifies YES / NO + Reminder: intro (MSG 1) + reply (MSG 2) + Reschedule: reply sent directly + Recall: same as Reschedule flow + Date in reply → AI skips to Day Check {/* Fork lines */} @@ -193,10 +207,11 @@ function LangGraphFlow() { {/* ══ YES BRANCH ══════════════════════════════════════════════════ */} - Reminder: Thank you for confirming! - Reschedule: What day & time? - "See you on [date & time]" - date in reply → Day Check ↘ + Reminder: Thank you for confirming! + Reschedule: What day & time? + Recall: What day & time? + "See you on [date & time]" + date in reply → Day Check ↘ {/* Yes2: Patient thanks */} @@ -206,7 +221,7 @@ function LangGraphFlow() { {/* Dashed shortcut: YES with date → Day Check */} , - label: "Call Opening Greeting", + label: "Call an existing patient for an appointment", 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: "callRecall", + icon: , + label: "Recall Call: Existing Patient Check-Up Offer", + description: "Lisa's opening line for a Patient Connection \"AI Call\" — offers an existing patient a routine exam & cleaning (most insurance covers it at no cost) and asks if they'd like to book. Use {firstName} and {officeName} as placeholders.", + value: callRecallGreeting, + onChange: setCallRecallGreeting, + placeholder: CALL_DEFAULTS.recallGreeting, + }, { key: "callReminder", icon: , @@ -1582,6 +1609,7 @@ export function AiChatSettingsCard() { setCallReminderGreeting(CALL_DEFAULTS.reminderGreeting); setCallNewPatientGreeting(CALL_DEFAULTS.newPatientGreeting); setCallGeneralFallback(CALL_DEFAULTS.generalFallback); + setCallRecallGreeting(CALL_DEFAULTS.recallGreeting); }} > Reset to defaults diff --git a/apps/Frontend/src/pages/patient-connection-page.tsx b/apps/Frontend/src/pages/patient-connection-page.tsx index d6e1427a..51e6643f 100755 --- a/apps/Frontend/src/pages/patient-connection-page.tsx +++ b/apps/Frontend/src/pages/patient-connection-page.tsx @@ -112,7 +112,7 @@ export default function PatientConnectionPage() { to: string; patientId: number; }) => { - return apiRequest("POST", "/api/twilio/make-ai-call", { to, patientId }); + return apiRequest("POST", "/api/twilio/make-ai-call", { to, patientId, purpose: "recall" }); }, onSuccess: () => { toast({