Files
DentalManagement09/apps/Backend/src/ai/reschedule-graph.ts
T
ffandClaude Sonnet 5 0cd095330a fix: AI recall-call flow, speech-to-text time parsing, and stale conversation state
- Reset conversation stage to a fresh start on every new voice call
  (recall and reminder alike), and on every hangup/timeout/goodbye,
  so an abandoned or completed call never leaves a patient stuck
  mid-flow for their next call or text.
- Add a staleness check to the SMS flow: after a long idle gap,
  check whether the patient's reply still reads as a continuation
  before resuming the stored stage, falling back to a friendly
  "it's been a while" re-greeting when unsure.
- Fix parseTime() to handle Twilio's speech-to-text output, which
  renders "pm"/"am" with periods and often drops the colon in a
  spoken time (e.g. "100 p.m." -> 1:00 PM) — this was silently
  failing and causing the AI to loop asking for a time it had
  already been given.
- Extend the voice Gather timeout so the AI doesn't hang up on a
  patient who just needs a few seconds to respond.
- Reword the recall-call opening line and rework the post-"yes"
  prompt to ask for day and time together.
- Fix rclone WebDAV server to serve only the backups folder instead
  of the entire backend app directory (was exposing .env and source).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:35:48 -04:00

1134 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
// ── 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,
provider: AiProvider = "google",
model?: 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 };
}
}
// LLM fallback for "next Monday", "Tuesday", etc.
try {
const llm = getLlm(provider, apiKey, model);
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,
provider: AiProvider = "google",
model?: 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 AI for natural language ───────────────────────────
try {
const llm = getLlm(provider, apiKey, model);
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<boolean> {
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<string | null> {
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<boolean> {
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, provider: AiProvider = "google", model?: string): Promise<string | null> {
// Twilio speech-to-text usually renders "pm"/"am" with periods ("p.m."),
// and often drops the colon in a spoken time too ("1:00pm" -> "100 pm",
// "12:30pm" -> "1230 pm") — normalize both before matching.
const t = message.toLowerCase()
.replace(/\ba\.m\.?/g, "am")
.replace(/\bp\.m\.?/g, "pm");
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}):([0-5]\d)\s*(am|pm)\b/);
const glued = !ampm ? t.match(/\b(\d{1,4})\s*(am|pm)\b/) : null;
const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/);
if (ampm) {
let h = parseInt(ampm[1]!);
const m = parseInt(ampm[2]!);
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 (glued) {
const digits = glued[1]!;
let h: number, m: number;
if (digits.length <= 2) { h = parseInt(digits); m = 0; }
else { h = parseInt(digits.slice(0, -2)); m = parseInt(digits.slice(-2)); }
if (h >= 1 && h <= 12 && m <= 59) {
if (glued[2] === "pm" && h < 12) h += 12;
if (glued[2] === "am" && h === 12) h = 0;
return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
}
}
if (clock) return clock[0]!;
try {
const llm = getLlm(provider, apiKey, model);
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, provider: AiProvider = "google", model?: string): Promise<string> {
try {
const llm = getLlm(provider, apiKey, model);
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<string, string> = {
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,
provider: AiProvider = "google",
model?: string
): 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, provider, model);
if (!parsedDate) {
const fallbacks: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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, provider, model
);
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
}
// Both date and time present — parse the full datetime
const parsed = await parseDatetimeFromMessage(message, apiKey, provider, model);
if (!parsed) {
const fallbacks: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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, provider, model
);
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<string, string> = {
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, provider, model);
if (!startTime) {
const fallbacks: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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, provider, model
);
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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, provider, model
);
return { reply, nextStage: "done" };
}
// ── asked_reschedule_confirm: patient answered the reschedule question ────
if (stage === "asked_reschedule_confirm") {
if (no(t)) {
const fallbacks: Record<string, string> = {
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, provider, model
);
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, provider, model);
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<string, string> = {
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<string, string> = {
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, provider
);
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
}
}
// Try to parse a date-only from the message
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
if (parsedDate) {
const { date, dateLabel } = parsedDate;
const dayCheck = await isOfficeDayOpen(date, userId);
if (!dayCheck.open) {
const fallbacks: Record<string, string> = {
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<string, string> = {
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, provider
);
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
}
// No date found — ask for day and time
const fallbacks: Record<string, string> = {
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, provider, model
);
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<string, string> = {
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, provider, model
);
return { reply, nextStage: "asked_reschedule_asap" };
}
if (wantsNextWeek(t)) {
const { mon, tue, wed } = getNextWeekDays();
const fallbacks: Record<string, string> = {
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, provider, model
);
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<string, string> = {
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, provider, model
);
return { reply, nextStage: "asked_reschedule_time" };
}
if (no(t)) {
const { mon, tue, wed } = getNextWeekDays();
const fallbacks: Record<string, string> = {
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, provider, model
);
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<string, string> = {
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, provider, model
);
return { reply, nextStage: "asked_reschedule_time" };
}
const { mon, tue, wed } = getNextWeekDays();
const fallbacks: Record<string, string> = {
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, provider, model);
if (!startTime) {
const fallbacks: Record<string, string> = {
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<string, string> = {
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, provider, model
);
return { reply, nextStage: "done" };
}
return { reply: tx, nextStage: "done" };
}