import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { prisma as db } from "@repo/db/client"; import { storage } from "../storage"; import { type ConversationStage, setPendingReschedule, getPendingReschedule, clearPendingReschedule, } from "./aiHandoffStore"; // ── Date helpers ────────────────────────────────────────────────────────────── const MONTHS = [ "January","February","March","April","May","June", "July","August","September","October","November","December", ]; const DAYS = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]; function formattedDate(d: Date): string { return `${DAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`; } function getTomorrow(): string { const t = new Date(); t.setDate(t.getDate() + 1); return formattedDate(t); } function getNextWeekDays(): { mon: string; tue: string; wed: string } { const today = new Date(); const dow = today.getDay(); const daysToMon = (8 - dow) % 7 || 7; const mon = new Date(today); mon.setDate(today.getDate() + daysToMon); const tue = new Date(mon); tue.setDate(mon.getDate() + 1); const wed = new Date(mon); wed.setDate(mon.getDate() + 2); return { mon: formattedDate(mon), tue: formattedDate(tue), wed: formattedDate(wed) }; } function getTomorrowDate(): { date: Date; label: string } { const d = new Date(); d.setDate(d.getDate() + 1); d.setUTCHours(0, 0, 0, 0); return { date: d, label: formattedDate(new Date(d.getTime() + d.getTimezoneOffset() * 60000)) }; } function getNextWeekDateObjects() { const today = new Date(); const dow = today.getDay(); const daysToMon = (8 - dow) % 7 || 7; const mkDate = (offset: number) => { const d = new Date(today); d.setDate(today.getDate() + offset); d.setUTCHours(0, 0, 0, 0); const label = formattedDate(new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset)); return { date: d, label }; }; return { mon: mkDate(daysToMon), tue: mkDate(daysToMon + 1), wed: mkDate(daysToMon + 2) }; } /** Format "HH:MM" (24-h) → "H:MM am/pm" */ export function timeLabel(hhmm: string): string { const [h, m] = hhmm.split(":").map(Number); const h12 = (h! % 12) || 12; const ampm = (h! >= 12) ? "pm" : "am"; return `${h12}:${String(m!).padStart(2, "0")} ${ampm}`; } // ── Datetime helpers ───────────────────────────────────────────────────────── /** Returns true if the message contains an explicit time (not just a date). */ function messageHasTime(msg: string): boolean { return ( /\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/i.test(msg) || /\b([01]?\d|2[0-3]):[0-5]\d\b/.test(msg) || /\b(morning|afternoon|evening|mañana|tarde|manhã|上午|下午|صباح|مساء|maten|aprèmidi)\b/i.test(msg) ); } /** * Parse only a date (no time) from a message like "5/18" or "next Monday". * Returns a UTC-midnight Date and a date-only display label, or null. */ export async function parseDateOnlyFromMessage( message: string, apiKey: string, ): Promise<{ date: Date; dateLabel: string } | null> { const now = new Date(); const todayStr = now.toLocaleDateString("en-CA"); // Regex: M/D, M-D, MM/DD patterns const slashMatch = message.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); if (slashMatch) { const mo = parseInt(slashMatch[1]!); const dy = parseInt(slashMatch[2]!); let yr = slashMatch[3] ? (slashMatch[3].length === 2 ? 2000 + parseInt(slashMatch[3]) : parseInt(slashMatch[3])) : now.getFullYear(); const candidate = new Date(`${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}T00:00:00`); if (candidate <= now) yr += 1; const dateStr = `${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}`; const parsedDate = new Date(dateStr + "T00:00:00.000Z"); if (!isNaN(parsedDate.getTime())) { const local = new Date(dateStr + "T00:00:00"); const dateLabel = `${DAYS[local.getDay()]!}, ${MONTHS[local.getMonth()]!} ${local.getDate()}`; return { date: parsedDate, dateLabel }; } } // Gemini fallback for "next Monday", "Tuesday", etc. try { const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); const res = await llm.invoke([ { role: "system", content: `Today is ${todayStr}. Extract only the date from the patient message. Return ONLY raw JSON: {"date":"YYYY-MM-DD"} - date must be a future date after ${todayStr}. - For relative names like "Monday" or "next Tuesday", use the next upcoming calendar date. - If no valid future date found, return {"date":null}.`, }, { role: "user", content: message }, ]); const jsonMatch = String(res.content).match(/\{[\s\S]*?"date"[\s\S]*?\}/); if (!jsonMatch) return null; const json = JSON.parse(jsonMatch[0]); if (!json.date || json.date === "null") return null; const dateStr = String(json.date).trim(); const parsedDate = new Date(dateStr + "T00:00:00.000Z"); if (isNaN(parsedDate.getTime())) return null; const local = new Date(dateStr + "T00:00:00"); const dateLabel = `${DAYS[local.getDay()]!}, ${MONTHS[local.getMonth()]!} ${local.getDate()}`; return { date: parsedDate, dateLabel }; } catch { return null; } } // ── Datetime parsing ────────────────────────────────────────────────────────── /** * Parse a natural-language date + time preference from a patient SMS. * Returns a future Date (UTC midnight), a 24-h startTime string, and a display label. */ async function parseDatetimeFromMessage( message: string, apiKey: string, ): Promise<{ date: Date; startTime: string; displayLabel: string } | null> { const now = new Date(); const todayStr = now.toLocaleDateString("en-CA"); // YYYY-MM-DD local // ── Step 1: regex pre-processing for common formats ─────────────────────── // Extract time (handles "10am", "10:30am", "2 pm", "14:00", "morning", "afternoon") let preTime: string | null = null; const ampmMatch = message.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i); if (ampmMatch) { let h = parseInt(ampmMatch[1]!); const m = ampmMatch[2] ? parseInt(ampmMatch[2]) : 0; const period = ampmMatch[3]!.toLowerCase(); if (period === "pm" && h < 12) h += 12; if (period === "am" && h === 12) h = 0; preTime = `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`; } else if (/\bmorning\b/i.test(message)) preTime = "09:00"; else if (/\bafternoon\b/i.test(message)) preTime = "13:00"; // Extract date for M/D, M-D, MM/DD, MM-DD, M/D/YYYY patterns let preDate: string | null = null; const slashMatch = message.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/); if (slashMatch) { const mo = parseInt(slashMatch[1]!); const dy = parseInt(slashMatch[2]!); let yr = slashMatch[3] ? (slashMatch[3].length === 2 ? 2000 + parseInt(slashMatch[3]) : parseInt(slashMatch[3])) : now.getFullYear(); // If the resulting date is in the past, assume next year const candidate = new Date(`${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}T00:00:00`); if (candidate <= now) yr += 1; preDate = `${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}`; } // If regex resolved both date and time, return immediately — no AI call needed if (preDate && preTime) { const parsedDate = new Date(preDate + "T00:00:00.000Z"); if (!isNaN(parsedDate.getTime())) { const localDate = new Date(preDate + "T00:00:00"); const displayLabel = `${DAYS[localDate.getDay()]!}, ${MONTHS[localDate.getMonth()]!} ${localDate.getDate()} at ${timeLabel(preTime)}`; return { date: parsedDate, startTime: preTime, displayLabel }; } } // ── Step 2: fall back to Google AI for natural language ─────────────────── try { const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); const res = await llm.invoke([ { role: "system", content: `Today is ${todayStr}. Extract the preferred appointment date and time from the patient's message. Return ONLY a raw JSON object — no markdown, no code block, no explanation: {"date":"YYYY-MM-DD","time":"HH:MM"} Rules: - date must be a future date after ${todayStr}, in YYYY-MM-DD format. - time must be 24-hour HH:MM format. "morning" → "09:00", "afternoon" → "13:00". - For relative names like "Monday" or "next Tuesday", use the next upcoming calendar date. - If no time is mentioned, use "09:00". - If no valid future date can be found, return {"date":null,"time":null}.`, }, { role: "user", content: message }, ]); // Robustly extract the JSON object from any wrapping text Gemini might add const raw = String(res.content); const jsonMatch = raw.match(/\{[\s\S]*?"date"[\s\S]*?\}/); if (!jsonMatch) return null; const json = JSON.parse(jsonMatch[0]); if (!json.date || json.date === "null") return null; // Normalise date to YYYY-MM-DD const dateStr = String(json.date).trim(); const parsedDate = new Date(dateStr + "T00:00:00.000Z"); if (isNaN(parsedDate.getTime())) return null; // Normalise time: accept "HH:MM", "H:MM", or "HH:MM AM/PM" let startTime = preTime ?? "09:00"; // use regex-extracted time if available if (!preTime && typeof json.time === "string") { const rawTime = json.time.trim(); const t24 = rawTime.match(/^(\d{1,2}):(\d{2})(?:\s*(am|pm))?$/i); if (t24) { let h = parseInt(t24[1]!); const m = parseInt(t24[2]!); const per = t24[3]?.toLowerCase(); if (per === "pm" && h < 12) h += 12; if (per === "am" && h === 12) h = 0; startTime = `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`; } } const localDate = new Date(dateStr + "T00:00:00"); const displayLabel = `${DAYS[localDate.getDay()]!}, ${MONTHS[localDate.getMonth()]!} ${localDate.getDate()} at ${timeLabel(startTime)}`; return { date: parsedDate, startTime, displayLabel }; } catch { return null; } } // ── Availability checks ─────────────────────────────────────────────────────── /** * Returns true if the given date+time is within the office's configured hours. * Falls back to true (unrestricted) if no hours are configured. */ export async function isWithinOfficeHours( date: Date, time: string, userId: number, ): Promise { try { const record = await storage.getOfficeHours(userId); if (!record?.data) return true; const data = record.data as any; const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const; const dayName = dayNames[date.getUTCDay()]!; // Override dates: all slots are open const ymd = date.toISOString().split("T")[0]!; if ((data.overrideDates as string[] | undefined)?.includes(ymd)) return true; // Check both doctors' and hygienists' hours — if within either, it's valid for (const group of [data.doctors, data.hygienists]) { const slot = group?.[dayName]; if (!slot?.enabled) continue; if ( (time >= slot.amStart && time < slot.amEnd) || (time >= slot.pmStart && time < slot.pmEnd) ) return true; } return false; } catch { return true; } } /** * Returns whether the office is open at all on a given date (day-level check). * Also returns the display day name (e.g. "Sunday") for use in error messages. */ export async function isOfficeDayOpen( date: Date, userId: number, ): Promise<{ open: boolean; displayDay: string }> { const displayDays = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]; const displayDay = displayDays[date.getUTCDay()]!; try { const record = await storage.getOfficeHours(userId); if (!record?.data) return { open: true, displayDay }; const data = record.data as any; const dayKeys = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const; const dayKey = dayKeys[date.getUTCDay()]!; // Override date — office is fully open const ymd = date.toISOString().split("T")[0]!; if ((data.overrideDates as string[] | undefined)?.includes(ymd)) return { open: true, displayDay }; // Open if at least one group (doctors or hygienists) has the day enabled const doctorDay = data.doctors?.[dayKey]; const hygDay = data.hygienists?.[dayKey]; if (doctorDay?.enabled || hygDay?.enabled) return { open: true, displayDay }; return { open: false, displayDay }; } catch { return { open: true, displayDay }; } } /** * Returns the office hours for a given day as a human-readable string, * e.g. "9:00 am – 12:00 pm and 1:00 pm – 5:00 pm". * Returns null if no hours are configured or the day is closed. */ export async function getOfficeHoursDisplay(date: Date, userId: number): Promise { try { const record = await storage.getOfficeHours(userId); if (!record?.data) return null; const data = record.data as any; const dayKeys = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const; const dayKey = dayKeys[date.getUTCDay()]!; for (const group of [data.doctors, data.hygienists]) { const slot = group?.[dayKey]; if (!slot?.enabled) continue; return `${timeLabel(slot.amStart)} – ${timeLabel(slot.amEnd)} and ${timeLabel(slot.pmStart)} – ${timeLabel(slot.pmEnd)}`; } return null; } catch { return null; } } /** * Returns true if no existing appointment for the patient's staff conflicts with * the new date+startTime. */ async function isSlotAvailable( patientId: number, newDate: Date, newStartTime: string, ): Promise { try { const today = new Date(); today.setHours(0, 0, 0, 0); const current = await db.appointment.findFirst({ where: { patientId, status: "scheduled", date: { gte: today } }, orderBy: { date: "asc" }, select: { staffId: true, startTime: true, endTime: true }, }); if (!current) return true; // Compute new end time preserving original appointment duration const [sh, sm] = current.startTime.split(":").map(Number); const [eh, em] = current.endTime.split(":").map(Number); const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!); const [nh, nm] = newStartTime.split(":").map(Number); const endTotal = nh! * 60 + nm! + durationMin; const newEndTime = `${String(Math.floor(endTotal / 60)).padStart(2,"0")}:${String(endTotal % 60).padStart(2,"0")}`; // Check for overlapping appointments for the same staff member on that day const conflicts = await db.appointment.findMany({ where: { staffId: current.staffId, date: newDate, status: { not: "cancelled" }, patientId: { not: patientId }, // exclude the patient's own appointment }, select: { startTime: true, endTime: true }, }); for (const c of conflicts) { if (newStartTime < c.endTime && newEndTime > c.startTime) return false; } return true; } catch { return true; } } // ── Time parsing (legacy) ───────────────────────────────────────────────────── export async function parseTime(message: string, apiKey: string): Promise { const t = message.toLowerCase(); if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00"; if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00"; const ampm = t.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/); const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/); if (ampm) { let h = parseInt(ampm[1]!); const m = ampm[2] ? parseInt(ampm[2]) : 0; if (ampm[3] === "pm" && h < 12) h += 12; if (ampm[3] === "am" && h === 12) h = 0; return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`; } if (clock) return clock[0]!; try { const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); const res = await llm.invoke([ { role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format. If no time is mentioned, return "null".' }, { role: "user", content: message }, ]); const raw = String(res.content).trim(); if (/^([01]?\d|2[0-3]):[0-5]\d$/.test(raw)) return raw; } catch { /* fall through */ } return null; } // ── Appointment update ──────────────────────────────────────────────────────── async function moveAppointment( patientId: number, newDate: Date, newStartTime: string, ): Promise<"ok" | "no_appointment"> { const today = new Date(); today.setHours(0, 0, 0, 0); const appt = await db.appointment.findFirst({ where: { patientId, status: "scheduled", date: { gte: today } }, orderBy: { date: "asc" }, }); if (!appt) return "no_appointment"; const [sh, sm] = appt.startTime.split(":").map(Number); const [eh, em] = appt.endTime.split(":").map(Number); const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!); const [nh, nm] = newStartTime.split(":").map(Number); const endTotal = nh! * 60 + nm! + durationMin; const newEndTime = `${String(Math.floor(endTotal / 60)).padStart(2,"0")}:${String(endTotal % 60).padStart(2,"0")}`; await storage.updateAppointment(appt.id, { date: newDate, startTime: newStartTime, endTime: newEndTime, status: "scheduled", movedByAi: true, } as any); return "ok"; } // ── Intent classifiers ──────────────────────────────────────────────────────── function yes(t: string) { return /\b(yes|yeah|yep|sure|ok|okay|please|absolutely|definitely|sí|si|sim|好的|نعم|wi|oke)\b/i.test(t); } function no(t: string) { return /\b(no|nope|not|don't|won't|can't|لا|pa ka|não)\b/i.test(t); } function wantsAsap(t: string) { return /\b(asap|soon|soonest|next day|tomorrow|quick|fastest|earliest|lo antes|mañana|amanhã|明天|明日|غدًا|demen)\b/i.test(t); } function wantsNextWeek(t: string) { return /\b(next week|semana|prochain|prochaine|下周|下週|الأسبوع القادم|semèn pwochèn)\b/i.test(t); } function prefersMonday(t: string) { return /monday|lunes|segunda|周一|月曜|الاثنين|lendi/i.test(t); } function prefersTuesday(t: string) { return /tuesday|martes|terça|周二|火曜|الثلاثاء|madi/i.test(t); } function prefersWednesday(t: string) { return /wednesday|miércoles|quarta|周三|水曜|الأربعاء|mèkredi/i.test(t); } // ── LLM helper ──────────────────────────────────────────────────────────────── async function llmReply(system: string, user: string, fallback: string, apiKey: string): Promise { try { const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey }); const res = await llm.invoke([ { role: "system", content: system }, { role: "user", content: user }, ]); return String(res.content).trim() || fallback; } catch { return fallback; } } // ── Transfer fallback ───────────────────────────────────────────────────────── const TRANSFER: Record = { English: "Our office staff will contact you shortly to help with rescheduling.", Spanish: "El personal de nuestra oficina le contactará pronto para ayudarle a reprogramar.", Portuguese: "Nossa equipe entrará em contato em breve para ajudá-lo a reagendar.", Mandarin: "我们的工作人员将很快与您联系以帮助重新安排预约。", Cantonese: "我們的工作人員將很快聯絡您以協助重新安排預約。", Arabic: "سيتصل بك موظفو مكتبنا قريباً لمساعدتك في إعادة الجدولة.", "Haitian Creole": "Anplwaye biwo nou yo pral kontakte ou byento pou ede ou repwograme.", }; // ── Main step function ──────────────────────────────────────────────────────── export async function runRescheduleStep( message: string, stage: ConversationStage, language: string, patientId: number, apiKey: string, userId: number = 0, ): Promise<{ reply: string; nextStage: ConversationStage }> { const lang = language || "English"; const t = message.toLowerCase(); const tx = TRANSFER[lang] ?? TRANSFER["English"]!; // ── asked_reschedule_datetime ───────────────────────────────────────────── // Patient provides date/time preference. // If only a date is given, ask for the time. If both are given, ask to confirm. if (stage === "asked_reschedule_datetime") { const hasTime = messageHasTime(message); if (!hasTime) { // Try to parse date only const parsedDate = await parseDateOnlyFromMessage(message, apiKey); if (!parsedDate) { const fallbacks: Record = { English: "I didn't catch that. What day would you prefer? For example: 'Monday', 'next Tuesday', or '5/18'.", Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: 'lunes', 'próximo martes' o '18/5'.", Portuguese: "Não entendi. Que dia você prefere? Por exemplo: 'Segunda', 'próxima terça' ou '18/5'.", Mandarin: "我没听清。您希望哪天?例如:'星期一'、'下周二'或'5月18日'。", Cantonese: "我沒聽清。您希望哪天?例如:'星期一'、'下週二'或'5月18日'。", Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: 'الاثنين' أو '18/5'.", "Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: 'Lendi', 'Madi pwochèn', oswa '5/18'.", }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } // Date found — check that the office is actually open that day const { date, dateLabel } = parsedDate; const dayCheck = await isOfficeDayOpen(date, userId); if (!dayCheck.open) { const fallbacks: 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: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } // Day is open — save date and ask for time setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel }); const fallbacks: 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}?`, }; const askTimeReply = await llmReply( `You are a friendly dental office assistant. The patient wants to reschedule to ${dateLabel} but hasn't given a time. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`, `Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, ); return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" }; } // Both date and time present — parse the full datetime const parsed = await parseDatetimeFromMessage(message, apiKey); if (!parsed) { const fallbacks: Record = { English: "I didn't catch that. What day and time would you prefer? For example: 'Monday at 10am' or '5/18 at 2pm'.", Spanish: "No entendí. ¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o '18/5 a las 2pm'.", Portuguese: "Não entendi. Que dia e horário você prefere? Por exemplo: 'Segunda às 10h' ou '18/5 às 14h'.", Mandarin: "我没听清。您希望哪天几点?例如:'星期一上午10点'或'5月18日下午2点'。", Cantonese: "我沒聽清。您希望哪天幾點?例如:'星期一上午10點'或'5月18日下午2點'。", Arabic: "لم أفهم. ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً'.", "Haitian Creole": "Mwen pa konprann. Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am' oswa '5/18 2pm'.", }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } const { date, startTime, displayLabel } = parsed; // Check the office is open on that day before confirming const dayCheck2 = await isOfficeDayOpen(date, userId); if (!dayCheck2.open) { // Extract a date-only label for the message (strip the "at HH:MM" part) const datePart = displayLabel.split(" at ")[0] ?? displayLabel; const fallbacks: Record = { English: `Our office is closed on ${datePart} (${dayCheck2.displayDay}). Can you please choose another day?`, Spanish: `Nuestra oficina está cerrada el ${datePart} (${dayCheck2.displayDay}). ¿Puede elegir otro día?`, Portuguese: `Nosso consultório está fechado em ${datePart} (${dayCheck2.displayDay}). Pode escolher outro dia?`, Mandarin: `我们诊所在 ${datePart}(${dayCheck2.displayDay})不开放。请您选择另一天好吗?`, Cantonese: `我們診所在 ${datePart}(${dayCheck2.displayDay})不開放。請您選擇另一天好嗎?`, Arabic: `مكتبنا مغلق في ${datePart} (${dayCheck2.displayDay}). هل يمكنك اختيار يوم آخر؟`, "Haitian Creole": `Biwo nou fèmen nan ${datePart} (${dayCheck2.displayDay}). Tanpri chwazi yon lòt jou?`, }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } setPendingReschedule(userId, patientId, { newDate: date, dayLabel: displayLabel, startTime }); const fallbacks: Record = { English: `Just to confirm — do you prefer ${displayLabel}?`, Spanish: `Solo para confirmar — ¿prefiere el ${displayLabel}?`, Portuguese: `Só para confirmar — você prefere ${displayLabel}?`, Mandarin: `确认一下——您希望的时间是 ${displayLabel} 吗?`, Cantonese: `確認一下——您希望的時間是 ${displayLabel} 嗎?`, Arabic: `فقط للتأكيد — هل تفضل ${displayLabel}؟`, "Haitian Creole": `Jis pou konfime — èske ou prefere ${displayLabel}?`, }; const confirmReply = await llmReply( `You are a friendly dental office AI assistant. The patient mentioned a date/time preference that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm: "Do you mean ${displayLabel}?" 1 sentence, natural and friendly. No formatting.`, `Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, ); return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" }; } // ── asked_reschedule_time_for_date ──────────────────────────────────────── // Patient was asked "What time do you prefer on [date]?" and now replies with a time. if (stage === "asked_reschedule_time_for_date") { const pending = getPendingReschedule(userId, patientId); if (!pending) { const fallbacks: Record = { English: "Sorry, I lost track. What day and time would you prefer?", Spanish: "Lo siento, perdí el hilo. ¿Qué día y hora prefiere?", Portuguese: "Desculpe, perdi o fio. Que dia e horário você prefere?", Mandarin: "抱歉,我失去了之前的信息。您希望哪天几点?", Cantonese: "抱歉,我失去了之前的資訊。您希望哪天幾點?", Arabic: "عذراً، فقدت المعلومات السابقة. ما اليوم والوقت الذي تفضله؟", "Haitian Creole": "Regrèt, mwen pèdi trak. Ki jou ak ki lè ou prefere?", }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } // Parse the time from the patient's reply const startTime = await parseTime(message, apiKey); if (!startTime) { const fallbacks: Record = { English: `I didn't catch the time. What time would you prefer on ${pending.dayLabel}? For example: '10am', '2pm', or '1:30pm'.`, Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am', '2pm' o '1:30pm'.`, Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h', '14h' ou '13:30'.`, Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点?例如:上午10点、下午2点或下午1:30。`, Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點?例如:上午10點、下午2點或下午1:30。`, Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`, "Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am, 2pm, oswa 1:30pm.`, }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time_for_date" }; } // Check that the requested time is within the office's working hours const withinHours = await isWithinOfficeHours(pending.newDate, startTime, userId); if (!withinHours) { const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, userId); const timeLbl = timeLabel(startTime); const fallbacks: 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: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time_for_date" }; } // Time is valid — build the full display label and ask for confirmation const fullLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`; setPendingReschedule(userId, patientId, { newDate: pending.newDate, dayLabel: fullLabel, startTime }); const fallbacks: Record = { English: `Just to confirm — do you prefer ${fullLabel}?`, Spanish: `Solo para confirmar — ¿prefiere el ${fullLabel}?`, Portuguese: `Só para confirmar — você prefere ${fullLabel}?`, Mandarin: `确认一下——您希望的时间是 ${fullLabel} 吗?`, Cantonese: `確認一下——您希望的時間是 ${fullLabel} 嗎?`, Arabic: `فقط للتأكيد — هل تفضل ${fullLabel}؟`, "Haitian Creole": `Jis pou konfime — èske ou prefere ${fullLabel}?`, }; const confirmReply = await llmReply( `You are a friendly dental office AI assistant. The patient wants ${fullLabel}. Ask them in ${lang} to confirm: "Do you mean ${fullLabel}?" 1 sentence, natural and friendly. No formatting.`, `Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, ); return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" }; } // ── asked_reschedule_confirm_datetime ───────────────────────────────────── // Patient confirms (yes) or corrects (no) the parsed date/time. if (stage === "asked_reschedule_confirm_datetime") { const pending = getPendingReschedule(userId, patientId); if (!pending) { // State lost — ask again from scratch const fallbacks: Record = { English: "Sorry, I lost track. What day and time would you prefer?", Spanish: "Lo siento, perdí el hilo. ¿Qué día y hora prefiere?", Portuguese: "Desculpe, perdi o fio. Que dia e horário você prefere?", Mandarin: "抱歉,我失去了之前的信息。您希望哪天几点?", Cantonese: "抱歉,我失去了之前的資訊。您希望哪天幾點?", Arabic: "عذراً، فقدت المعلومات السابقة. ما اليوم والوقت الذي تفضله؟", "Haitian Creole": "Regrèt, mwen pèdi trak. Ki jou ak ki lè ou prefere?", }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } // Patient said NO / wants to change if (no(t)) { clearPendingReschedule(userId, patientId); const fallbacks: Record = { English: "No problem! What day and time would you prefer? For example: 'Monday at 10am' or '5/18 at 2pm'.", Spanish: "¡Sin problema! ¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am'.", Portuguese: "Sem problema! Que dia e horário você prefere? Por exemplo: 'Segunda às 10h'.", Mandarin: "没关系!您希望哪天几点?例如:'星期一上午10点'。", Cantonese: "沒問題!您希望哪天幾點?例如:'星期一上午10點'。", Arabic: "لا بأس! ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً'.", "Haitian Creole": "Pa gen pwoblèm! Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am'.", }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } // Patient said YES — proceed with availability check const { newDate: date, startTime: pendingTime, dayLabel: displayLabel } = pending; const startTime = pendingTime ?? "09:00"; clearPendingReschedule(userId, patientId); // Check office hours — include actual hours in the message so patient knows what's available const withinHours = await isWithinOfficeHours(date, startTime, userId); if (!withinHours) { const hoursDisplay = await getOfficeHoursDisplay(date, userId); const timeLbl = timeLabel(startTime); // Extract date portion for the message (strip "at HH:MM" if present) const datePart = displayLabel.split(" at ")[0] ?? displayLabel; const fallbacks: Record = { English: hoursDisplay ? `Our office is not available at ${timeLbl} on ${datePart}. Our hours are ${hoursDisplay}. What other time do you prefer?` : `Our office is not available at ${timeLbl} on ${datePart}. What other time do you prefer?`, Spanish: hoursDisplay ? `Nuestra oficina no está disponible a las ${timeLbl} el ${datePart}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?` : `Nuestra oficina no está disponible a las ${timeLbl} el ${datePart}. ¿Qué otro horario prefiere?`, Portuguese: hoursDisplay ? `Nosso consultório não está disponível às ${timeLbl} em ${datePart}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?` : `Nosso consultório não está disponível às ${timeLbl} em ${datePart}. Que outro horário você prefere?`, Mandarin: hoursDisplay ? `我们诊所在 ${datePart} ${timeLbl} 不开放。我们的工作时间是 ${hoursDisplay}。您希望改什么时间?` : `我们诊所在 ${datePart} ${timeLbl} 不开放。您希望改什么时间?`, Cantonese: hoursDisplay ? `我們診所在 ${datePart} ${timeLbl} 不開放。我們的工作時間是 ${hoursDisplay}。您希望改什麼時間?` : `我們診所在 ${datePart} ${timeLbl} 不開放。您希望改什麼時間?`, Arabic: hoursDisplay ? `مكتبنا غير متاح في ${timeLbl} يوم ${datePart}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟` : `مكتبنا غير متاح في ${timeLbl} يوم ${datePart}. ما وقت آخر تفضله؟`, "Haitian Creole": hoursDisplay ? `Biwo nou pa disponib a ${timeLbl} nan ${datePart}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?` : `Biwo nou pa disponib a ${timeLbl} nan ${datePart}. Ki lòt lè ou prefere?`, }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } // Check slot availability const available = await isSlotAvailable(patientId, date, startTime); if (!available) { const fallbacks: Record = { English: `Sorry, ${displayLabel} is not available. What other time do you prefer?`, Spanish: `Lo sentimos, ${displayLabel} no está disponible. ¿Qué otro horario prefiere?`, Portuguese: `Infelizmente ${displayLabel} não está disponível. Que outro horário você prefere?`, Mandarin: `抱歉,${displayLabel} 该时间段不可用。您有其他偏好的时间吗?`, Cantonese: `抱歉,${displayLabel} 該時間段不可用。您有其他偏好的時間嗎?`, Arabic: `عذراً، ${displayLabel} غير متاح. ما وقت آخر تفضله؟`, "Haitian Creole": `Regrèt, ${displayLabel} pa disponib. Ki lòt lè ou prefere?`, }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } // Move the appointment const result = await moveAppointment(patientId, date, startTime); if (result === "no_appointment") { const fallbacks: Record = { English: `I wasn't able to find your appointment to update. Our dental receptionist will contact you to confirm ${displayLabel}.`, Spanish: `No encontré su cita. El personal le contactará para confirmar el ${displayLabel}.`, Portuguese: `Não encontrei sua consulta. Nossa equipe confirmará ${displayLabel}.`, Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${displayLabel}。`, Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${displayLabel}。`, Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${displayLabel}.`, "Haitian Creole": `Mwen pa jwenn randevou ou. Anplwaye nou yo pral kontakte ou pou konfime ${displayLabel}.`, }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" }; } // Success — confirm to patient const fallback = `Your appointment is moved to ${displayLabel}. Our dental receptionist will confirm it with you tomorrow.`; const reply = await llmReply( `You are a friendly dental office AI assistant. The patient's appointment has been successfully moved to ${displayLabel}. Write a warm confirmation message in ${lang}: tell them the appointment is moved to ${displayLabel}, and that our dental receptionist will confirm it with them tomorrow. 2 sentences max, no formatting.`, `Appointment rescheduled to ${displayLabel}.`, fallback, apiKey, ); return { reply, nextStage: "done" }; } // ── asked_reschedule_confirm: patient answered the reschedule question ──── if (stage === "asked_reschedule_confirm") { if (no(t)) { const fallbacks: Record = { English: "No problem! Feel free to reach out whenever you're ready. Have a great day!", Spanish: "¡Sin problema! No dude en contactarnos cuando esté listo. ¡Que tenga un buen día!", Portuguese: "Sem problema! Entre em contato quando estiver pronto. Tenha um ótimo dia!", Mandarin: "没关系!随时联系我们。祝您有美好的一天!", Cantonese: "沒問題!隨時聯繫我們。祝您有美好的一天!", Arabic: "لا بأس! لا تتردد في التواصل معنا متى كنت مستعداً. أتمنى لك يوماً رائعاً!", "Haitian Creole": "Pa gen pwoblèm! Kontakte nou nenpòt ki lè ou prèt. Pase yon bèl jounen!", }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; const reply = await llmReply( `You are a friendly dental office assistant. The patient does not want to reschedule. Write a warm, brief closing message in ${lang}. 1 sentence, no formatting.`, `Patient said: "${message}"`, fallback, apiKey, ); return { reply, nextStage: "done" }; } // Patient confirmed they want to reschedule. // First check if they already included a date (and optionally time) in the same message. if (yes(t)) { const hasTime = messageHasTime(message); if (hasTime) { // Try to parse full datetime from the message const parsed = await parseDatetimeFromMessage(message, apiKey); if (parsed) { const { date, startTime, displayLabel } = parsed; const dayCheck = await isOfficeDayOpen(date, userId); if (!dayCheck.open) { const datePart = displayLabel.split(" at ")[0] ?? displayLabel; const fallbacks: Record = { English: `Our office is closed on ${datePart} (${dayCheck.displayDay}). Can you please choose another day?`, Spanish: `Nuestra oficina está cerrada el ${datePart} (${dayCheck.displayDay}). ¿Puede elegir otro día?`, Portuguese: `Nosso consultório está fechado em ${datePart} (${dayCheck.displayDay}). Pode escolher outro dia?`, Mandarin: `我们诊所在 ${datePart}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`, Cantonese: `我們診所在 ${datePart}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`, Arabic: `مكتبنا مغلق في ${datePart} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`, "Haitian Creole": `Biwo nou fèmen nan ${datePart} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`, }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } setPendingReschedule(userId, patientId, { newDate: date, dayLabel: displayLabel, startTime }); const fallbacks: Record = { English: `Just to confirm — do you prefer ${displayLabel}?`, Spanish: `Solo para confirmar — ¿prefiere el ${displayLabel}?`, Portuguese: `Só para confirmar — você prefere ${displayLabel}?`, Mandarin: `确认一下——您希望的时间是 ${displayLabel} 吗?`, Cantonese: `確認一下——您希望的時間是 ${displayLabel} 嗎?`, Arabic: `فقط للتأكيد — هل تفضل ${displayLabel}؟`, "Haitian Creole": `Jis pou konfime — èske ou prefere ${displayLabel}?`, }; const confirmReply = await llmReply( `You are a friendly dental office AI assistant. The patient mentioned a date/time that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm. 1 sentence, natural and friendly. No formatting.`, `Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, ); return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" }; } } // Try to parse a date-only from the message const parsedDate = await parseDateOnlyFromMessage(message, apiKey); if (parsedDate) { const { date, dateLabel } = parsedDate; const dayCheck = await isOfficeDayOpen(date, userId); if (!dayCheck.open) { const fallbacks: 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: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" }; } // Day is open — save and ask for time setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel }); const fallbacks: 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}?`, }; const askTimeReply = await llmReply( `You are a friendly dental office assistant. The patient wants ${dateLabel}. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`, `Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, ); return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" }; } // No date found — ask for day and time const fallbacks: Record = { English: "What day and time would you like? For example: 'Monday at 10am' or 'next Tuesday afternoon'.", Spanish: "¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o 'martes por la tarde'.", Portuguese: "Que dia e horário você prefere? Por exemplo: 'Segunda às 10h' ou 'terça de tarde'.", Mandarin: "您希望哪天几点?例如:'星期一上午10点'或'下周二下午'。", Cantonese: "您希望哪天幾點?例如:'星期一上午10點'或'下週二下午'。", Arabic: "ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً' أو 'الثلاثاء بعد الظهر'.", "Haitian Creole": "Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am' oswa 'Madi apremidi'.", }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; const reply = await llmReply( `You are a friendly dental office assistant. The patient wants to reschedule. Ask them in ${lang} what day and time they prefer. Give 1-2 examples like "Monday at 10am" or "next Tuesday afternoon". 1-2 sentences, no formatting.`, `Patient wants to reschedule.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_datetime" }; } return { reply: tx, nextStage: "done" }; } // ── asked_reschedule_preference: patient chose ASAP or next week ────────── if (stage === "asked_reschedule_preference") { if (wantsAsap(t) || yes(t)) { const tomorrow = getTomorrow(); const fallbacks: Record = { English: `Can you come in tomorrow, ${tomorrow}?`, Spanish: `¿Podría venir mañana, ${tomorrow}?`, Portuguese: `Você pode vir amanhã, ${tomorrow}?`, Mandarin: `您明天(${tomorrow})能来吗?`, Cantonese: `您明天(${tomorrow})能來嗎?`, Arabic: `هل يمكنك الحضور غداً، ${tomorrow}؟`, "Haitian Creole": `Èske ou ka vini demen, ${tomorrow}?`, }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; const reply = await llmReply( `You are a friendly dental office assistant. Ask the patient in ${lang} if they can come in tomorrow, ${tomorrow}. 1 sentence, no formatting.`, `Patient wants to reschedule ASAP.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_asap" }; } if (wantsNextWeek(t)) { const { mon, tue, wed } = getNextWeekDays(); const fallbacks: Record = { English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`, Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`, Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom?`, Mandarin: `我可以安排您在下周预约。${mon}、${tue} 或 ${wed} 方便吗?`, Cantonese: `我可以安排您在下週預約。${mon}、${tue} 或 ${wed} 方便嗎?`, Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`, "Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay?`, }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; const reply = await llmReply( `You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`, `Patient prefers next week.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_next_week" }; } return { reply: tx, nextStage: "done" }; } // ── asked_reschedule_asap: patient answered "Can you come tomorrow?" ─────── if (stage === "asked_reschedule_asap") { if (yes(t)) { const { date, label } = getTomorrowDate(); setPendingReschedule(userId, patientId, { newDate: date, dayLabel: label }); const fallbacks: Record = { English: `${label} it is! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`, Spanish: `¡${label} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`, Portuguese: `${label} ótimo! Você prefere manhã (9h–12h) ou tarde (13h–17h)?`, Mandarin: `${label},太好了!您想预约上午(9点–12点)还是下午(1点–5点)?`, Cantonese: `${label},太好了!您想預約上午(9點–12點)還是下午(1點–5點)?`, Arabic: `${label} ممتاز! هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟`, "Haitian Creole": `${label} pafè! Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?`, }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; const reply = await llmReply( `You are a friendly dental office assistant. The patient confirmed ${label}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`, `Patient confirmed tomorrow.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_time" }; } if (no(t)) { const { mon, tue, wed } = getNextWeekDays(); const fallbacks: Record = { English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work?`, Spanish: `¡Sin problema! ¿Qué le parece la semana que viene? ¿El ${mon}, ${tue} o el ${wed}?`, Portuguese: `Sem problema! E na semana que vem? ${mon}, ${tue} ou ${wed}?`, Mandarin: `没关系!下周怎么样?${mon}、${tue} 或 ${wed} 方便吗?`, Cantonese: `沒問題!下週怎麼樣?${mon}、${tue} 或 ${wed} 方便嗎?`, Arabic: `لا بأس! ماذا عن الأسبوع القادم؟ ${mon} أو ${tue} أو ${wed}؟`, "Haitian Creole": `Pa gen pwoblèm! Semèn pwochèn? ${mon}, ${tue}, oswa ${wed}?`, }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; const reply = await llmReply( `You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`, `Patient can't come tomorrow.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_next_week" }; } return { reply: tx, nextStage: "done" }; } // ── asked_reschedule_next_week: patient choosing Mon/Tue/Wed ───────────── if (stage === "asked_reschedule_next_week") { const nwd = getNextWeekDateObjects(); let chosen: { date: Date; label: string } | null = null; if (prefersMonday(t)) chosen = nwd.mon; if (prefersTuesday(t)) chosen = nwd.tue; if (prefersWednesday(t)) chosen = nwd.wed; if (chosen) { setPendingReschedule(userId, patientId, { newDate: chosen.date, dayLabel: chosen.label }); const day = chosen.label; const fallbacks: Record = { English: `${day} works! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`, Spanish: `¡${day} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`, Portuguese: `${day} ótimo! Você prefere manhã (9h–12h) ou tarde (13h–17h)?`, Mandarin: `${day},好的!您想预约上午(9点–12点)还是下午(1点–5点)?`, Cantonese: `${day},好的!您想預約上午(9點–12點)還是下午(1點–5點)?`, Arabic: `${day} رائع! هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟`, "Haitian Creole": `${day} pafè! Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?`, }; const fallback = fallbacks[lang] ?? fallbacks["English"]!; const reply = await llmReply( `You are a friendly dental office assistant. The patient chose ${day}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`, `Patient chose ${day}.`, fallback, apiKey, ); return { reply, nextStage: "asked_reschedule_time" }; } const { mon, tue, wed } = getNextWeekDays(); const fallbacks: Record = { English: `Which day works best — ${mon}, ${tue}, or ${wed}?`, Spanish: `¿Qué día le viene mejor — el ${mon}, ${tue} o el ${wed}?`, Portuguese: `Qual dia é melhor — ${mon}, ${tue} ou ${wed}?`, Mandarin: `哪天最方便——${mon}、${tue} 还是 ${wed}?`, Cantonese: `哪天最方便——${mon}、${tue} 還是 ${wed}?`, Arabic: `أي يوم هو الأفضل — ${mon} أو ${tue} أو ${wed}؟`, "Haitian Creole": `Ki jou ki pi bon — ${mon}, ${tue}, oswa ${wed}?`, }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_next_week" }; } // ── asked_reschedule_time: patient picked morning / afternoon / specific time ── if (stage === "asked_reschedule_time") { const pending = getPendingReschedule(userId, patientId); if (!pending) return { reply: tx, nextStage: "done" }; const startTime = await parseTime(message, apiKey); if (!startTime) { const fallbacks: Record = { English: "I didn't catch the time. Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?", Spanish: "No entendí la hora. ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?", Portuguese: "Não entendi o horário. Você prefere manhã (9h–12h) ou tarde (13h–17h)?", Mandarin: "我没听清时间。您想预约上午(9点–12点)还是下午(1点–5点)?", Cantonese: "我沒聽清時間。您想預約上午(9點–12點)還是下午(1點–5點)?", Arabic: "لم أفهم الوقت. هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟", "Haitian Creole": "Mwen pa konprann lè a. Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?", }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time" }; } const updateResult = await moveAppointment(patientId, pending.newDate, startTime); clearPendingReschedule(userId, patientId); const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`; if (updateResult === "no_appointment") { const fallbacks: Record = { English: `I couldn't find your appointment to update. Our staff will contact you to confirm ${apptLabel}.`, Spanish: `No encontré su cita para actualizar. El personal le contactará para confirmar el ${apptLabel}.`, Portuguese: `Não encontrei sua consulta. Nossa equipe entrará em contato para confirmar ${apptLabel}.`, Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${apptLabel}。`, Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${apptLabel}。`, Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${apptLabel}.`, "Haitian Creole": `Mwen pa jwenn randevou ou. Anplwaye nou yo pral kontakte ou pou konfime ${apptLabel}.`, }; return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" }; } const fallback = `Your appointment has been moved to ${apptLabel}. Our dental receptionist will confirm it with you tomorrow.`; const reply = await llmReply( `You are a friendly dental office assistant. The patient's appointment has been rescheduled to ${apptLabel}. Confirm in ${lang}: say the appointment is moved to ${apptLabel} and that our dental receptionist will confirm it with them tomorrow. 2 sentences, no formatting.`, `Appointment moved to ${apptLabel}.`, fallback, apiKey, ); return { reply, nextStage: "done" }; } return { reply: tx, nextStage: "done" }; }