feat: AI chat system with LangGraph, multi-step patient flows, and appointment rescheduling

- Add floating chat window Hand-off to AI toggle (per-patient) and after-hours AI toggle (global)
- Add LangGraph-powered appointment reminder flow: AI introduces itself, classifies YES/NO, handles confirmation with appointment date/time
- Add multi-step rescheduling flow: ASAP vs next week, tomorrow offer, Mon/Tue/Wed picker, morning/afternoon time slot — automatically updates appointment in DB
- Add new patient / after-hours flow: new vs existing patient, dental insurance check, MassHealth Selenium eligibility check (auto-uses saved member ID + DOB for existing patients), self-pay fallback
- Add AI Chat Settings page (Settings → Advanced) with editable greeting templates and LangGraph flow diagrams for both reminder and new-patient flows
- Add Schedule a New Patient template option in chat window, starts new-patient conversation flow
- Add GET/PUT endpoints for AI handoff, after-hours handoff, and AI chat templates
- Add multilingual support (7 languages) across all AI reply nodes with LLM generation and hardcoded fallbacks
- Add pending reschedule in-memory store and conversation stage tracking across all flows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-07 23:21:06 -04:00
parent 86dd685342
commit 9908e5b5fd
317 changed files with 6533 additions and 274 deletions

View File

@@ -0,0 +1,95 @@
// In-memory store for per-patient AI handoff toggle, conversation stage,
// and per-user after-hours handoff toggle.
// Conversation key: `${userId}:${patientId}`
export type ConversationStage =
| "initial" // default — no active conversation
| "reminder_initial" // office sent a reminder, waiting for first patient reply
| "greeted" // reminder intro sent, waiting for yes/no
| "done" // conversation complete
| "new_patient_greeted" // new-patient greeting sent, waiting for patient intent
| "asked_new_or_existing" // AI asked "new or existing patient?"
| "asked_new_patient_insurance" // AI asked new patient about insurance
| "asked_existing_insurance" // AI asked existing patient about same insurance
| "asked_appointment_time" // AI asked when they'd like to come
| "awaiting_masshealth_info" // AI asked for Member ID + DOB, waiting for reply
| "asked_appointment_preference" // Selenium: ACTIVE — AI asked check-up vs problem
| "asked_self_pay" // Selenium: INACTIVE — AI asked if self-pay exam
| "asked_reschedule_confirm" // AI asked "Would you like to reschedule?"
| "asked_reschedule_preference" // AI asked ASAP vs next week
| "asked_reschedule_asap" // AI asked "Can you come tomorrow?"
| "asked_reschedule_next_week" // AI offered Mon/Tue/Wed next week
| "asked_reschedule_time"; // Day confirmed — AI asked morning or afternoon
const handoffStore = new Map<string, boolean>();
const stageStore = new Map<string, ConversationStage>();
const afterHoursStore = new Map<number, boolean>(); // keyed by userId
function convKey(userId: number, patientId: number): string {
return `${userId}:${patientId}`;
}
// ── Per-patient handoff toggle (default ON) ───────────────────────────────────
export function getHandoff(userId: number, patientId: number): boolean {
const k = convKey(userId, patientId);
return handoffStore.has(k) ? handoffStore.get(k)! : true;
}
export function setHandoff(userId: number, patientId: number, enabled: boolean): void {
handoffStore.set(convKey(userId, patientId), enabled);
}
// ── Per-user after-hours handoff toggle (default ON) ─────────────────────────
export function getAfterHoursHandoff(userId: number): boolean {
return afterHoursStore.has(userId) ? afterHoursStore.get(userId)! : true;
}
export function setAfterHoursHandoff(userId: number, enabled: boolean): void {
afterHoursStore.set(userId, enabled);
}
// ── Conversation stage ────────────────────────────────────────────────────────
export function getStage(userId: number, patientId: number): ConversationStage {
return stageStore.get(convKey(userId, patientId)) ?? "initial";
}
export function setStage(userId: number, patientId: number, stage: ConversationStage): void {
stageStore.set(convKey(userId, patientId), stage);
}
// Called when office sends an outbound reminder — marks next patient reply
// as the start of a reminder conversation.
export function resetConversation(userId: number, patientId: number): void {
stageStore.set(convKey(userId, patientId), "reminder_initial");
}
// Called when office sends the new-patient greeting — marks next patient reply
// as the start of the new-patient conversation flow.
export function startNewPatientConversation(userId: number, patientId: number): void {
stageStore.set(convKey(userId, patientId), "new_patient_greeted");
}
// ── Pending reschedule data ───────────────────────────────────────────────────
// Holds the confirmed new date while AI waits for a time-slot answer.
interface PendingReschedule {
newDate: Date; // JS Date for the new appointment day (midnight UTC)
dayLabel: string; // human-readable, e.g. "Tuesday, May 19"
}
const pendingRescheduleStore = new Map<string, PendingReschedule>();
export function setPendingReschedule(userId: number, patientId: number, data: PendingReschedule): void {
pendingRescheduleStore.set(convKey(userId, patientId), data);
}
export function getPendingReschedule(userId: number, patientId: number): PendingReschedule | undefined {
return pendingRescheduleStore.get(convKey(userId, patientId));
}
export function clearPendingReschedule(userId: number, patientId: number): void {
pendingRescheduleStore.delete(convKey(userId, patientId));
}

View File

@@ -0,0 +1,435 @@
import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import type { ConversationStage } from "./aiHandoffStore";
// ── Graph state ───────────────────────────────────────────────────────────────
const GraphState = Annotation.Root({
message: Annotation<string>(),
stage: Annotation<string>(),
intent: Annotation<string>(),
reply: Annotation<string>(),
language: Annotation<string>(),
nextStage: Annotation<string>(),
});
type GraphStateType = typeof GraphState.State;
// ── Transfer-to-staff fallback (multilingual) ─────────────────────────────────
const TRANSFER: Record<string, string> = {
English: "Thank you for reaching out! Our office staff will assist you shortly.",
Spanish: "¡Gracias por comunicarse! Un miembro del personal de la oficina le atenderá en breve.",
Portuguese: "Obrigado pelo contato! Nossa equipe entrará em contato em breve.",
Mandarin: "感谢您的联系!我们的工作人员将很快为您提供帮助。",
Cantonese: "感謝您的聯繫!我們的工作人員將很快為您提供幫助。",
Arabic: "شكراً لتواصلك! سيتواصل معك أحد موظفي المكتب قريباً.",
"Haitian Creole": "Mèsi dèske ou kontakte nou! Yon manm ekip biwo a pral ede ou byento.",
};
function transferMsg(lang: string): string {
return TRANSFER[lang] ?? TRANSFER["English"]!;
}
// ── Intent classifiers ────────────────────────────────────────────────────────
function wantsAppointment(text: string): boolean {
return /appointment|schedule|cleaning|checkup|teeth|tooth|cavity|pain|dental|filling|crown|whitening|x-ray|exam|hygienist|dentist/i.test(text);
}
function isNewPatient(text: string): boolean {
return /new patient|first time|first visit|never been|brand new|haven't been|i am new/i.test(text);
}
function isExistingPatient(text: string): boolean {
return /existing|been there|have been|already|before|i have been|returning|came before|i was there/i.test(text);
}
function hasMassHealth(text: string): boolean {
return /masshealth|mass health|medicaid|masscare/i.test(text);
}
function hasOtherInsurance(text: string): boolean {
return /blue cross|delta dental|cigna|aetna|united|metlife|guardian|humana|tufts|harvard pilgrim|bmchp|yes|i have|my insurance|i do/i.test(text);
}
function hasNoInsurance(text: string): boolean {
return /no insurance|uninsured|self.pay|self pay|i don't|don't have|no i don't|i have no/i.test(text);
}
function sameInsurance(text: string): boolean {
return /yes|same|still have|haven't changed|no change/i.test(text);
}
function changedInsurance(text: string): boolean {
return /no|changed|different|new insurance|switched|lost|expired/i.test(text);
}
// ── LLM reply helper ──────────────────────────────────────────────────────────
async function llmReply(
system: string,
userMsg: 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: userMsg },
]);
return String(res.content).trim() || fallback;
} catch {
return fallback;
}
}
// ── Graph nodes ───────────────────────────────────────────────────────────────
// Classify intent based on current stage
function classifyNode(state: GraphStateType) {
const text = state.message.toLowerCase();
const stage = state.stage as ConversationStage;
let intent = "other";
if (stage === "new_patient_greeted") {
intent = wantsAppointment(text) ? "wants_appointment" : "other";
} else if (stage === "asked_new_or_existing") {
if (isNewPatient(text)) intent = "new_patient";
else if (isExistingPatient(text)) intent = "existing_patient";
} else if (stage === "asked_new_patient_insurance") {
if (hasMassHealth(text)) intent = "masshealth";
else if (hasNoInsurance(text)) intent = "no_insurance";
else if (hasOtherInsurance(text)) intent = "other_insurance";
} else if (stage === "asked_existing_insurance") {
if (sameInsurance(text)) intent = "same_insurance";
else if (changedInsurance(text)) intent = "changed_insurance";
} else if (stage === "asked_appointment_time") {
intent = "appointment_time";
} else if (stage === "asked_appointment_preference") {
intent = "appointment_preference_reply";
} else if (stage === "asked_self_pay") {
intent = "self_pay_reply";
}
return { intent };
}
function routeNode(state: GraphStateType): string {
const stage = state.stage as ConversationStage;
const intent = state.intent;
if (stage === "new_patient_greeted") return intent === "wants_appointment" ? "ask_new_or_existing" : "transfer";
if (stage === "asked_new_or_existing") return intent === "new_patient" ? "ask_new_patient_insurance" : intent === "existing_patient" ? "ask_existing_insurance" : "transfer";
if (stage === "asked_new_patient_insurance") return intent === "masshealth" ? "ask_masshealth_info" : intent === "no_insurance" ? "ask_appointment_time" : "transfer";
if (stage === "asked_existing_insurance") return intent === "same_insurance" ? "ask_appointment_time" : "transfer";
if (stage === "asked_appointment_time") return "acknowledge_appointment_time";
if (stage === "asked_appointment_preference") return "handle_appointment_preference";
if (stage === "asked_self_pay") return "handle_self_pay";
return "transfer";
}
// ── Response nodes ────────────────────────────────────────────────────────────
async function askNewOrExistingNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallbacks: Record<string, string> = {
English: "Are you a new patient or an existing patient?",
Spanish: "¿Es usted un paciente nuevo o ya ha visitado nuestra clínica antes?",
Portuguese: "Você é um paciente novo ou já veio ao nosso consultório antes?",
Mandarin: "您是新患者还是现有患者?",
Cantonese: "您係新病人定係舊病人?",
Arabic: "هل أنت مريض جديد أم مريض حالي؟",
"Haitian Creole": "Èske ou se yon nouvo pasyan oswa yon pasyan egzistan?",
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. Ask the patient in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`,
`Patient wants an appointment. Ask if new or existing.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "asked_new_or_existing" };
}
async function askNewPatientInsuranceNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallbacks: Record<string, string> = {
English: "Do you have any dental insurance?",
Spanish: "¿Tiene seguro dental?",
Portuguese: "Você tem plano odontológico?",
Mandarin: "您有牙科保险吗?",
Cantonese: "您有牙科保險嗎?",
Arabic: "هل لديك تأمين أسنان؟",
"Haitian Creole": "Èske ou gen asirans dantè?",
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. Ask the new patient in ${lang} if they have dental insurance. One sentence, no formatting.`,
`New patient confirmed. Ask about insurance.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "asked_new_patient_insurance" };
}
async function askExistingInsuranceNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallbacks: Record<string, string> = {
English: "Do you still have the same insurance?",
Spanish: "¿Sigue teniendo el mismo seguro?",
Portuguese: "Você ainda tem o mesmo plano?",
Mandarin: "您还有相同的保险吗?",
Cantonese: "您仍然有相同的保險嗎?",
Arabic: "هل لا تزال تمتلك نفس التأمين؟",
"Haitian Creole": "Èske ou toujou gen menm asirans lan?",
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. Ask the existing patient in ${lang} if they still have the same dental insurance on file. One sentence, no formatting.`,
`Existing patient confirmed. Ask about insurance.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "asked_existing_insurance" };
}
async function askMassHealthInfoNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallbacks: Record<string, string> = {
English: "I can check your MassHealth coverage! Please text me your Member ID and date of birth.",
Spanish: "¡Puedo verificar su cobertura de MassHealth! Por favor envíeme su número de miembro y fecha de nacimiento.",
Portuguese: "Posso verificar sua cobertura MassHealth! Por favor envie seu número de membro e data de nascimento.",
Mandarin: "我可以查看您的MassHealth保险请发送您的会员ID和出生日期。",
Cantonese: "我可以查核您的MassHealth保險請傳送您的會員ID和出生日期。",
Arabic: "يمكنني التحقق من تغطية MassHealth الخاصة بك! من فضلك أرسل لي رقم العضوية وتاريخ الميلاد.",
"Haitian Creole": "Mwen ka verifye asirans MassHealth ou! Tanpri voye ID manm ou ak dat nesans ou.",
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. The patient has MassHealth. Tell them in ${lang} that you can check their coverage and ask them to send their Member ID and date of birth. 1-2 sentences, no formatting.`,
`Patient has MassHealth. Ask for member ID and DOB.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "awaiting_masshealth_info" };
}
async function askAppointmentTimeNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallbacks: Record<string, string> = {
English: "When would you like to make an appointment?",
Spanish: "¿Cuándo le gustaría hacer una cita?",
Portuguese: "Quando você gostaria de agendar uma consulta?",
Mandarin: "您想什么时候预约?",
Cantonese: "您想幾時預約?",
Arabic: "متى تريد تحديد موعد؟",
"Haitian Creole": "Ki lè ou ta renmen fè yon randevou?",
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. Ask the patient in ${lang} when they would like to schedule their appointment. One sentence, no formatting.`,
`Ask when to schedule.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "asked_appointment_time" };
}
async function acknowledgeAppointmentTimeNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallbacks: Record<string, string> = {
English: "Thank you! Our office staff will confirm your appointment details shortly.",
Spanish: "¡Gracias! El personal de la oficina confirmará los detalles de su cita en breve.",
Portuguese: "Obrigado! Nossa equipe confirmará os detalhes da sua consulta em breve.",
Mandarin: "谢谢!我们的工作人员将很快确认您的预约详情。",
Cantonese: "多謝!我們的工作人員將很快確認您的預約詳情。",
Arabic: "شكراً! سيؤكد فريق مكتبنا تفاصيل موعدك قريباً.",
"Haitian Creole": "Mèsi! Ekip biwo nou an pral konfime detay randevou ou a byento.",
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. The patient stated their preferred appointment time. Acknowledge in ${lang} and tell them the office staff will confirm shortly. 1-2 sentences, no formatting.`,
`Patient said: "${state.message}". Acknowledge.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "done" };
}
// ── Post-Selenium: appointment preference (MassHealth ACTIVE) ─────────────────
async function handleAppointmentPreferenceNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallbacks: Record<string, string> = {
English: "When would you like to come in? Are you looking for a routine check-up and teeth cleaning, or do you have a tooth problem or pain?",
Spanish: "¿Cuándo le gustaría venir? ¿Busca una revisión rutinaria y limpieza dental, o tiene algún problema dental o dolor?",
Portuguese: "Quando gostaria de vir? Você busca uma consulta de rotina e limpeza, ou tem algum problema dentário ou dor?",
Mandarin: "您想什么时候来?您是想做常规检查和洗牙,还是您有牙齿问题或疼痛?",
Cantonese: "您想幾時來?您是想做例行檢查和洗牙,還是您有牙齒問題或疼痛?",
Arabic: "متى تودّ الحضور؟ هل تبحث عن فحص روتيني وتنظيف أسنان، أم أن لديك مشكلة في الأسنان أو ألماً؟",
"Haitian Creole": "Ki lè ou ta renmen vini? Èske ou ap chèche yon egzamen woutin ak netwayaj dan, oswa èske ou gen yon pwoblèm dan oswa doulè?",
};
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. The patient's MassHealth is active. In ${lang}, ask when they would like to come in and whether they want a routine check-up and teeth cleaning, or if they have a dental problem or pain. 1-2 sentences, no formatting.`,
`MassHealth is active. Ask appointment preference.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "asked_appointment_time" };
}
// ── Post-Selenium: self-pay offer (MassHealth INACTIVE) ───────────────────────
async function handleSelfPayNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const text = state.message.toLowerCase();
// Classify yes/no for self-pay
const acceptsSelfPay = /yes|sure|ok|okay|yep|yeah|sí|si|claro|sim|confirmado|好的|نعم|wi|oke/i.test(text);
const declinesSelfPay = /no|nope|can't|won't|not interested|no puedo|não|لا|pa ka/i.test(text);
if (declinesSelfPay) {
const declineFallbacks: Record<string, string> = {
English: "No problem! If you ever change your mind or have any questions, feel free to reach out. Our staff is happy to help.",
Spanish: "¡Sin problema! Si cambia de opinión o tiene alguna pregunta, no dude en contactarnos.",
Portuguese: "Sem problema! Se mudar de ideia ou tiver dúvidas, entre em contato. Nossa equipe está feliz em ajudar.",
Mandarin: "没关系!如果您改变主意或有任何问题,随时联系我们,我们的工作人员很乐意帮助您。",
Cantonese: "沒問題!如果您改變主意或有任何問題,隨時聯繫我們。",
Arabic: "لا بأس! إذا غيّرت رأيك أو كان لديك أي سؤال، لا تتردد في التواصل معنا.",
"Haitian Creole": "Pa gen pwoblèm! Si ou chanje lide ou oswa gen kesyon, pa ezite kontakte nou.",
};
const fallback = declineFallbacks[lang] ?? declineFallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. The patient declined a self-pay appointment. In ${lang}, politely close the conversation and invite them to reach out anytime. 1-2 sentences, no formatting.`,
`Patient declined self-pay.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "done" };
}
if (acceptsSelfPay) {
const acceptFallbacks: Record<string, string> = {
English: "When would you like to come in for your examination?",
Spanish: "¿Cuándo le gustaría venir para su examen?",
Portuguese: "Quando você gostaria de vir para seu exame?",
Mandarin: "您想什么时候来进行检查?",
Cantonese: "您想幾時來進行檢查?",
Arabic: "متى تودّ الحضور للفحص؟",
"Haitian Creole": "Ki lè ou ta renmen vini pou egzamen ou?",
};
const fallback = acceptFallbacks[lang] ?? acceptFallbacks["English"]!;
const reply = apiKey
? await llmReply(
`You are a friendly dental office AI assistant. The patient agreed to a self-pay examination. In ${lang}, ask when they would like to come in. One sentence, no formatting.`,
`Patient agreed to self-pay. Ask when to come in.`,
fallback, apiKey
)
: fallback;
return { reply, nextStage: "asked_appointment_time" };
}
// Ambiguous — transfer to staff
return { reply: transferMsg(lang), nextStage: "done" };
}
function transferNode(state: GraphStateType) {
const lang = state.language || "English";
return { reply: transferMsg(lang), nextStage: "done" };
}
// ── Graph assembly ────────────────────────────────────────────────────────────
const graph = new StateGraph(GraphState)
.addNode("classify", classifyNode)
.addNode("ask_new_or_existing", askNewOrExistingNode)
.addNode("ask_new_patient_insurance", askNewPatientInsuranceNode)
.addNode("ask_existing_insurance", askExistingInsuranceNode)
.addNode("ask_masshealth_info", askMassHealthInfoNode)
.addNode("ask_appointment_time", askAppointmentTimeNode)
.addNode("acknowledge_appointment_time", acknowledgeAppointmentTimeNode)
.addNode("handle_appointment_preference",handleAppointmentPreferenceNode)
.addNode("handle_self_pay", handleSelfPayNode)
.addNode("transfer", transferNode)
.addEdge(START, "classify")
.addConditionalEdges("classify", routeNode, {
ask_new_or_existing: "ask_new_or_existing",
ask_new_patient_insurance: "ask_new_patient_insurance",
ask_existing_insurance: "ask_existing_insurance",
ask_masshealth_info: "ask_masshealth_info",
ask_appointment_time: "ask_appointment_time",
acknowledge_appointment_time: "acknowledge_appointment_time",
handle_appointment_preference: "handle_appointment_preference",
handle_self_pay: "handle_self_pay",
transfer: "transfer",
})
.addEdge("ask_new_or_existing", END)
.addEdge("ask_new_patient_insurance", END)
.addEdge("ask_existing_insurance", END)
.addEdge("ask_masshealth_info", END)
.addEdge("ask_appointment_time", END)
.addEdge("acknowledge_appointment_time", END)
.addEdge("handle_appointment_preference", END)
.addEdge("handle_self_pay", END)
.addEdge("transfer", END)
.compile();
// ── Public API ────────────────────────────────────────────────────────────────
export async function runNewPatientStep(
message: string,
stage: ConversationStage,
language: string,
apiKey: string
): Promise<{ reply: string; nextStage: ConversationStage }> {
const result = await graph.invoke(
{ message, stage, intent: "", reply: "", language, nextStage: "" },
{ configurable: { apiKey } }
);
return {
reply: result.reply || transferMsg(language),
nextStage: (result.nextStage as ConversationStage) || "done",
};
}

View File

@@ -2,44 +2,80 @@ import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
const GraphState = Annotation.Root({
message: Annotation<string>(),
intent: Annotation<string>(),
reply: Annotation<string>(),
message: Annotation<string>(),
intent: Annotation<string>(),
reply: Annotation<string>(),
language: Annotation<string>(),
appointmentDatetime: Annotation<string>(),
});
type GraphStateType = typeof GraphState.State;
// Keyword-based intent classifier — fast and deterministic for yes/no
// ── Intent classifier — multilingual yes/no keywords ─────────────────────────
function classifyNode(state: GraphStateType) {
const text = state.message.toLowerCase().trim();
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely)\b/;
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going)\b/;
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|confirmo|de acuerdo|seguro|estaré|sim|confirmado|com certeza|好的|确认|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/;
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going|no puedo|no podré|cancelar|reprogramar|ocupado|lo siento|não posso|não vou|reagendar|占线|无法|取消|لا|لا أستطيع|إلغاء|pa kapab|pa ka)\b/;
if (yesPatterns.test(text)) return { intent: "yes" };
if (noPatterns.test(text)) return { intent: "no" };
if (noPatterns.test(text)) return { intent: "no" };
return { intent: "other" };
}
function routeByIntent(state: GraphStateType): string {
if (state.intent === "yes") return "thank_you";
if (state.intent === "no") return "reschedule";
if (state.intent === "yes") return "confirm";
if (state.intent === "no") return "reschedule";
return END;
}
async function thankYouNode(state: GraphStateType, config: any) {
// ── Confirmation fallbacks (with appointment datetime) ────────────────────────
function buildConfirmFallback(lang: string, apptDatetime: string): string {
const appt = apptDatetime || "your scheduled appointment";
const map: Record<string, string> = {
English: `Thank you for your confirmation! We look forward to seeing you on ${appt}.`,
Spanish: `¡Gracias por confirmar! Le esperamos el ${appt}.`,
Portuguese: `Obrigado por confirmar! Aguardamos a sua visita em ${appt}.`,
Mandarin: `感谢您的确认!我们期待在 ${appt} 见到您。`,
Cantonese: `感謝您的確認!我們期待在 ${appt} 見到您。`,
Arabic: `شكراً على تأكيدك! نتطلع إلى رؤيتك في ${appt}.`,
"Haitian Creole": `Mèsi dèske ou konfime! N'ap tann ou ${appt}.`,
};
return map[lang] ?? map["English"]!;
}
// ── Reschedule fallbacks ──────────────────────────────────────────────────────
const RESCHEDULE_FALLBACKS: Record<string, string> = {
English: "It is understandable! Would you like to reschedule?",
Spanish: "¡Lo entendemos! ¿Le gustaría reprogramar su cita?",
Portuguese: "Entendemos! Gostaria de reagendar a sua consulta?",
Mandarin: "我们理解!您想重新安排预约吗?",
Cantonese: "我們理解!您想重新安排預約嗎?",
Arabic: "نتفهم ذلك! هل تود إعادة جدولة موعدك؟",
"Haitian Creole": "Nou konprann! Èske ou ta renmen repwograme randevou ou?",
};
// ── LangGraph nodes ───────────────────────────────────────────────────────────
async function confirmNode(state: GraphStateType, config: any) {
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallback = "Thank you for confirming your appointment! We look forward to seeing you.";
const lang = state.language || "English";
const appt = state.appointmentDatetime || "";
const fallback = buildConfirmFallback(lang, appt);
if (!apiKey) return { reply: fallback };
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const apptClause = appt ? ` Their appointment is on ${appt}.` : "";
const response = await llm.invoke([
{
role: "system",
content:
"You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking a patient who just confirmed their appointment. Do not add any formatting or extra text.",
`You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking the patient for confirming their appointment and reminding them of the date and time.${apptClause} You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`,
},
{ role: "user", content: `Patient replied: "${state.message}"` },
]);
@@ -51,7 +87,8 @@ async function thankYouNode(state: GraphStateType, config: any) {
async function rescheduleNode(state: GraphStateType, config: any) {
const apiKey: string | undefined = config?.configurable?.apiKey;
const fallback = "We understand! Our assistant will contact you shortly to help reschedule.";
const lang = state.language || "English";
const fallback = RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!;
if (!apiKey) return { reply: fallback };
@@ -61,7 +98,7 @@ async function rescheduleNode(state: GraphStateType, config: any) {
{
role: "system",
content:
"You are a friendly dental office assistant. Write a short, empathetic SMS reply (1-2 sentences max) to a patient who can't make their appointment. Tell them an assistant will contact them soon to reschedule. Do not add any formatting or extra text.",
`You are a friendly dental office assistant. The patient cannot make their appointment. Write a short, empathetic SMS reply (1 sentence max) that says it is understandable and asks if they would like to reschedule. You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`,
},
{ role: "user", content: `Patient replied: "${state.message}"` },
]);
@@ -71,30 +108,34 @@ async function rescheduleNode(state: GraphStateType, config: any) {
}
}
// ── Graph ─────────────────────────────────────────────────────────────────────
const graph = new StateGraph(GraphState)
.addNode("classify", classifyNode)
.addNode("thank_you", thankYouNode)
.addNode("classify", classifyNode)
.addNode("confirm", confirmNode)
.addNode("reschedule", rescheduleNode)
.addEdge(START, "classify")
.addConditionalEdges("classify", routeByIntent, {
thank_you: "thank_you",
confirm: "confirm",
reschedule: "reschedule",
[END]: END,
[END]: END,
})
.addEdge("thank_you", END)
.addEdge("confirm", END)
.addEdge("reschedule", END)
.compile();
export async function runReminderGraph(
patientMessage: string,
apiKey: string
patientMessage: string,
apiKey: string,
language = "English",
appointmentDatetime = ""
): Promise<{ reply: string | null; intent: string | null }> {
const result = await graph.invoke(
{ message: patientMessage, intent: "", reply: "" },
{ message: patientMessage, intent: "", reply: "", language, appointmentDatetime },
{ configurable: { apiKey } }
);
return {
reply: result.reply || null,
reply: result.reply || null,
intent: result.intent || null,
};
}

View File

@@ -0,0 +1,470 @@
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 MonThu (days 14)
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 (9am12pm) or afternoon (1pm5pm)?`,
Spanish: `¡${label} perfecto! ¿Prefiere la mañana (9am12pm) o la tarde (1pm5pm)?`,
Portuguese: `${label} ótimo! Você prefere manhã (9h12h) ou tarde (13h17h)?`,
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 (9am12pm) oswa apremidi (1pm5pm)?`,
};
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 (9am12pm) or afternoon (1pm5pm)?`,
Spanish: `¡${day} perfecto! ¿Prefiere la mañana (9am12pm) o la tarde (1pm5pm)?`,
Portuguese: `${day} ótimo! Você prefere manhã (9h12h) ou tarde (13h17h)?`,
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 (9am12pm) oswa apremidi (1pm5pm)?`,
};
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 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 (9am12pm) or afternoon (1pm5pm)?",
Spanish: "No entendí la hora. ¿Prefiere la mañana (9am12pm) o la tarde (1pm5pm)?",
Portuguese: "Não entendi o horário. Você prefere manhã (9h12h) ou tarde (13h17h)?",
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 (9am12pm) oswa apremidi (1pm5pm)?",
};
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" };
}