- Replace in-memory Maps in aiHandoffStore with DB-backed async functions using new patient_conversation table (stage + aiHandoff per patient) - Add afterHoursEnabled to ai_settings table (persists across restarts) - Fix runtime crash in reschedule-graph: mon/tue/wed variables were out of scope in the next-week fallback branch (ReferenceError) - Wire rescheduleGreeting and generalFallback chat templates through to LangGraph nodes so user-configured messages take effect - Add otherNode to reminder-graph to handle unclassified patient replies (e.g. "I want another appointment") and route to booking flow - Fetch chatTemplates once per webhook request instead of per stage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
472 lines
25 KiB
TypeScript
472 lines
25 KiB
TypeScript
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||
import { prisma as db } from "@repo/db/client";
|
||
import { storage } from "../storage";
|
||
import {
|
||
type ConversationStage,
|
||
setPendingReschedule,
|
||
getPendingReschedule,
|
||
clearPendingReschedule,
|
||
} from "./aiHandoffStore";
|
||
|
||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||
|
||
const MONTHS = [
|
||
"January","February","March","April","May","June",
|
||
"July","August","September","October","November","December",
|
||
];
|
||
const DAYS = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
|
||
|
||
function formattedDate(d: Date): string {
|
||
return `${DAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
||
}
|
||
|
||
/** Get the day-of-week (0=Sun…6=Sat) of the patient's next scheduled appointment. */
|
||
async function getAppointmentDow(patientId: number): Promise<number> {
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
const appt = await db.appointment.findFirst({
|
||
where: { patientId, status: "scheduled", date: { gte: today } },
|
||
orderBy: { date: "asc" },
|
||
select: { date: true },
|
||
});
|
||
if (!appt) return -1;
|
||
return new Date(appt.date).getUTCDay();
|
||
}
|
||
|
||
function getTomorrow(): string {
|
||
const t = new Date();
|
||
t.setDate(t.getDate() + 1);
|
||
return formattedDate(t);
|
||
}
|
||
|
||
function getNextWeekDays(): { mon: string; tue: string; wed: string } {
|
||
const today = new Date();
|
||
const dow = today.getDay();
|
||
// Days until next Monday (always at least 1 day away, never 0)
|
||
const daysToMon = (8 - dow) % 7 || 7;
|
||
const mon = new Date(today); mon.setDate(today.getDate() + daysToMon);
|
||
const tue = new Date(mon); tue.setDate(mon.getDate() + 1);
|
||
const wed = new Date(mon); wed.setDate(mon.getDate() + 2);
|
||
return { mon: formattedDate(mon), tue: formattedDate(tue), wed: formattedDate(wed) };
|
||
}
|
||
|
||
// ── Date objects for rescheduling ─────────────────────────────────────────────
|
||
|
||
function getTomorrowDate(): { date: Date; label: string } {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() + 1);
|
||
d.setUTCHours(0, 0, 0, 0);
|
||
return { date: d, label: formattedDate(new Date(d.getTime() + d.getTimezoneOffset() * 60000)) };
|
||
}
|
||
|
||
function getNextWeekDateObjects(): { mon: { date: Date; label: string }; tue: { date: Date; label: string }; wed: { date: Date; label: string } } {
|
||
const today = new Date();
|
||
const dow = today.getDay();
|
||
const daysToMon = (8 - dow) % 7 || 7;
|
||
|
||
const mkDate = (offset: number) => {
|
||
const d = new Date(today);
|
||
d.setDate(today.getDate() + offset);
|
||
d.setUTCHours(0, 0, 0, 0);
|
||
const label = formattedDate(new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset));
|
||
return { date: d, label };
|
||
};
|
||
return { mon: mkDate(daysToMon), tue: mkDate(daysToMon + 1), wed: mkDate(daysToMon + 2) };
|
||
}
|
||
|
||
// ── Time parsing ──────────────────────────────────────────────────────────────
|
||
|
||
/** Parse patient's time preference into 24-h "HH:MM" string or null. */
|
||
async function parseTime(message: string, apiKey: string): Promise<string | null> {
|
||
const t = message.toLowerCase();
|
||
|
||
// Keyword shortcuts
|
||
if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00";
|
||
if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00";
|
||
|
||
// Numeric patterns: "10am", "10:30", "2pm", "14:00"
|
||
const ampm = t.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/);
|
||
const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/);
|
||
|
||
if (ampm) {
|
||
let h = parseInt(ampm[1]!);
|
||
const m = ampm[2] ? parseInt(ampm[2]) : 0;
|
||
if (ampm[3] === "pm" && h < 12) h += 12;
|
||
if (ampm[3] === "am" && h === 12) h = 0;
|
||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||
}
|
||
if (clock) return clock[0]!;
|
||
|
||
// LLM fallback
|
||
try {
|
||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||
const res = await llm.invoke([
|
||
{ role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format (e.g., "10:00", "14:30"). If no time is mentioned, return "null".' },
|
||
{ role: "user", content: message },
|
||
]);
|
||
const raw = String(res.content).trim();
|
||
if (/^([01]?\d|2[0-3]):[0-5]\d$/.test(raw)) return raw;
|
||
} catch { /* fall through */ }
|
||
|
||
return null;
|
||
}
|
||
|
||
// ── Appointment update ────────────────────────────────────────────────────────
|
||
|
||
async function moveAppointment(
|
||
patientId: number,
|
||
newDate: Date,
|
||
newStartTime: string, // "HH:MM"
|
||
): Promise<string> {
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
const appt = await db.appointment.findFirst({
|
||
where: { patientId, status: "scheduled", date: { gte: today } },
|
||
orderBy: { date: "asc" },
|
||
});
|
||
if (!appt) return "no_appointment";
|
||
|
||
// Preserve original duration
|
||
const [sh, sm] = appt.startTime.split(":").map(Number);
|
||
const [eh, em] = appt.endTime.split(":").map(Number);
|
||
const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!);
|
||
const [nh, nm] = newStartTime.split(":").map(Number);
|
||
const endTotalMin = nh! * 60 + nm! + durationMin;
|
||
const newEndTime = `${String(Math.floor(endTotalMin / 60)).padStart(2, "0")}:${String(endTotalMin % 60).padStart(2, "0")}`;
|
||
|
||
await storage.updateAppointment(appt.id, {
|
||
date: newDate,
|
||
startTime: newStartTime,
|
||
endTime: newEndTime,
|
||
status: "scheduled",
|
||
} as any);
|
||
|
||
return "ok";
|
||
}
|
||
|
||
// ── Intent classifiers ────────────────────────────────────────────────────────
|
||
|
||
function yes(t: string) {
|
||
return /\b(yes|yeah|yep|sure|ok|okay|please|absolutely|definitely|sí|si|sim|好的|نعم|wi|oke)\b/i.test(t);
|
||
}
|
||
function no(t: string) {
|
||
return /\b(no|nope|not|don't|won't|can't|لا|pa ka|não)\b/i.test(t);
|
||
}
|
||
function wantsAsap(t: string) {
|
||
return /\b(asap|soon|soonest|next day|tomorrow|quick|fastest|earliest|lo antes|mañana|amanhã|明天|明日|غدًا|demen)\b/i.test(t);
|
||
}
|
||
function wantsNextWeek(t: string) {
|
||
return /\b(next week|semana|prochain|prochaine|下周|下週|الأسبوع القادم|semèn pwochèn)\b/i.test(t);
|
||
}
|
||
function prefersMonday(t: string) { return /monday|lunes|segunda|周一|月曜|الاثنين|lendi/i.test(t); }
|
||
function prefersTuesday(t: string) { return /tuesday|martes|terça|周二|火曜|الثلاثاء|madi/i.test(t); }
|
||
function prefersWednesday(t: string) { return /wednesday|miércoles|quarta|周三|水曜|الأربعاء|mèkredi/i.test(t); }
|
||
|
||
// ── LLM helper ────────────────────────────────────────────────────────────────
|
||
|
||
async function llmReply(system: string, user: string, fallback: string, apiKey: string): Promise<string> {
|
||
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<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,
|
||
): Promise<{ reply: string; nextStage: ConversationStage }> {
|
||
|
||
const lang = language || "English";
|
||
const t = message.toLowerCase();
|
||
const tx = TRANSFER[lang] ?? TRANSFER["English"]!;
|
||
|
||
// ── asked_reschedule_confirm: patient answered "Would you like to reschedule?" ──
|
||
if (stage === "asked_reschedule_confirm") {
|
||
if (no(t)) {
|
||
const fallbacks: Record<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
|
||
);
|
||
return { reply, nextStage: "done" };
|
||
}
|
||
|
||
if (yes(t)) {
|
||
// Check if original appointment was Mon–Thu (days 1–4)
|
||
const dow = await getAppointmentDow(patientId);
|
||
const isMonToThu = dow >= 1 && dow <= 4;
|
||
|
||
if (isMonToThu) {
|
||
// Offer ASAP or next week
|
||
const fallbacks: Record<string, string> = {
|
||
English: "Would you like to reschedule as soon as possible, or would you prefer next week?",
|
||
Spanish: "¿Le gustaría reprogramar lo antes posible, o prefiere la semana que viene?",
|
||
Portuguese: "Gostaria de reagendar o mais rápido possível, ou prefere a semana que vem?",
|
||
Mandarin: "您想尽快重新安排预约,还是下周更方便?",
|
||
Cantonese: "您想盡快重新安排預約,還是下週更方便?",
|
||
Arabic: "هل تفضل إعادة الجدولة في أقرب وقت ممكن، أم تفضل الأسبوع القادم؟",
|
||
"Haitian Creole": "Èske ou ta renmen repwograme pi vit posib, oswa ou prefere semèn pwochèn?",
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient wants to reschedule. Their original appointment was on a weekday. Ask in ${lang} whether they prefer to reschedule as soon as possible or next week. 1 sentence, no formatting.`,
|
||
`Patient said: "${message}"`, fallback, apiKey
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_preference" };
|
||
} else {
|
||
// Original appointment was Fri/Sat/Sun — go straight to next week
|
||
const { mon, tue, wed } = getNextWeekDays();
|
||
const fallbacks: Record<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 para você?`,
|
||
Mandarin: `我可以安排您在下周预约。${mon}、${tue} 或 ${wed} 方便吗?`,
|
||
Cantonese: `我可以安排您在下週預約。${mon}、${tue} 或 ${wed} 方便嗎?`,
|
||
Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`,
|
||
"Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay pou ou?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. Offer the patient next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
|
||
`Patient wants to reschedule.`, fallback, apiKey
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||
}
|
||
}
|
||
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|
||
|
||
// ── asked_reschedule_preference: patient chose ASAP or next week ──────────
|
||
if (stage === "asked_reschedule_preference") {
|
||
if (wantsAsap(t) || yes(t)) {
|
||
const tomorrow = getTomorrow();
|
||
const fallbacks: Record<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
|
||
);
|
||
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 para você?`,
|
||
Mandarin: `我可以安排您在下周预约。${mon}、${tue} 或 ${wed} 方便吗?`,
|
||
Cantonese: `我可以安排您在下週預約。${mon}、${tue} 或 ${wed} 方便嗎?`,
|
||
Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`,
|
||
"Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay pou ou?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
|
||
`Patient prefers next week.`, fallback, apiKey
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||
}
|
||
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|
||
|
||
// ── asked_reschedule_asap: patient answered "Can you come tomorrow?" ───────
|
||
if (stage === "asked_reschedule_asap") {
|
||
if (yes(t)) {
|
||
const { date, label } = getTomorrowDate();
|
||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: label });
|
||
|
||
const fallbacks: Record<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
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_time" };
|
||
}
|
||
|
||
if (no(t)) {
|
||
// Can't make tomorrow — offer next week instead
|
||
const { mon, tue, wed } = getNextWeekDays();
|
||
const fallbacks: Record<string, string> = {
|
||
English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work for you?`,
|
||
Spanish: `¡Sin problema! ¿Qué le parece la semana que viene? ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`,
|
||
Portuguese: `Sem problema! E na semana que vem? ${mon}, ${tue} ou ${wed} seria bom?`,
|
||
Mandarin: `没关系!下周怎么样?${mon}、${tue} 或 ${wed} 方便吗?`,
|
||
Cantonese: `沒問題!下週怎麼樣?${mon}、${tue} 或 ${wed} 方便嗎?`,
|
||
Arabic: `لا بأس! ماذا عن الأسبوع القادم؟ هل ${mon} أو ${tue} أو ${wed} يناسبك؟`,
|
||
"Haitian Creole": `Pa gen pwoblèm! Ki sa ki dire semèn pwochèn? ${mon}, ${tue}, oswa ${wed} ka travay?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`,
|
||
`Patient can't come tomorrow.`, fallback, apiKey
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||
}
|
||
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|
||
|
||
// ── asked_reschedule_next_week: patient choosing Mon/Tue/Wed ─────────────
|
||
if (stage === "asked_reschedule_next_week") {
|
||
const nwd = getNextWeekDateObjects();
|
||
let chosen: { date: Date; label: string } | null = null;
|
||
if (prefersMonday(t)) chosen = nwd.mon;
|
||
if (prefersTuesday(t)) chosen = nwd.tue;
|
||
if (prefersWednesday(t)) chosen = nwd.wed;
|
||
|
||
if (chosen) {
|
||
setPendingReschedule(userId, patientId, { newDate: chosen.date, dayLabel: chosen.label });
|
||
|
||
const day = chosen.label;
|
||
const fallbacks: Record<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
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_time" };
|
||
}
|
||
|
||
// Day not clearly detected — ask again with the specific options
|
||
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}?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
return { reply: fallback, nextStage: "asked_reschedule_next_week" };
|
||
}
|
||
|
||
// ── asked_reschedule_time: patient picked morning / afternoon / specific time ──
|
||
if (stage === "asked_reschedule_time") {
|
||
const pending = getPendingReschedule(userId, patientId);
|
||
|
||
if (!pending) {
|
||
// Edge case: lost state — fall back gracefully
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|
||
|
||
const startTime = await parseTime(message, apiKey);
|
||
|
||
if (!startTime) {
|
||
// Couldn't parse time — ask again
|
||
const fallbacks: Record<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" };
|
||
}
|
||
|
||
// Update the appointment in the database
|
||
const updateResult = await moveAppointment(patientId, pending.newDate, startTime);
|
||
clearPendingReschedule(userId, patientId);
|
||
|
||
const [h, m] = startTime.split(":").map(Number);
|
||
const h12 = h! % 12 || 12;
|
||
const ampm = h! >= 12 ? "pm" : "am";
|
||
const timeLabel = `${h12}:${String(m!).padStart(2, "0")} ${ampm}`;
|
||
const apptLabel = `${pending.dayLabel} at ${timeLabel}`;
|
||
|
||
if (updateResult === "no_appointment") {
|
||
const fallbacks: Record<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 para atualizar. Nossa equipe entrará em contato para confirmar ${apptLabel}.`,
|
||
Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${apptLabel}。`,
|
||
Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${apptLabel}。`,
|
||
Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${apptLabel}.`,
|
||
"Haitian Creole": `Mwen pa jwenn randevou ou pou mete ajou. Anplwaye nou yo pral kontakte ou pou konfime ${apptLabel}.`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" };
|
||
}
|
||
|
||
// Success
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Your appointment has been moved to ${apptLabel}. See you then!`,
|
||
Spanish: `Su cita ha sido cambiada al ${apptLabel}. ¡Hasta entonces!`,
|
||
Portuguese: `Sua consulta foi remarcada para ${apptLabel}. Até lá!`,
|
||
Mandarin: `您的预约已更改为${apptLabel}。到时见!`,
|
||
Cantonese: `您的預約已更改為${apptLabel}。到時見!`,
|
||
Arabic: `تم تغيير موعدك إلى ${apptLabel}. نراك قريباً!`,
|
||
"Haitian Creole": `Randevou ou a deplase ale nan ${apptLabel}. N'ap wè ou lè sa a!`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient's appointment has been successfully rescheduled to ${apptLabel}. Confirm in ${lang} with enthusiasm. 1 sentence, no formatting.`,
|
||
`Appointment moved to ${apptLabel}.`, fallback, apiKey
|
||
);
|
||
return { reply, nextStage: "done" };
|
||
}
|
||
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|