feat: AI SMS reminder flow with two-message intro, smart reschedule with availability checks
- Reminder flow: send AI self-introduction as message 1 (Twilio REST API), intent response as message 2 (TwiML) so intro always arrives first - LangGraph reminder graph: classify yes/no/other from patient reply; 'no' now asks 'When would you like to reschedule?' directly - Reschedule flow: new asked_reschedule_datetime stage replaces multi-step ASAP/next-week flow - Date-only reply (e.g. '5/18'): ask for time separately, then confirm - Date+time reply (e.g. '5/18 at 10am'): go straight to confirmation - new asked_reschedule_time_for_date and asked_reschedule_confirm_datetime stages - Date/time parsing: regex handles M/D and am/pm formats first; falls back to Gemini for natural language - Day-level office hours check: if requested day is closed (e.g. Sunday), reply 'Our office is closed on [date]. Choose another day?' - Time-level office hours check: if requested time is outside working hours (e.g. 12pm during lunch), reply with actual hours (e.g. '9:00 am – 12:00 pm and 1:00 pm – 5:00 pm') - Slot availability check: verifies no conflicting appointment for same staff member - After appointment confirmed: patient thank-you reply triggers warm closing with upcoming appointment time - Schedule page: office hours summary bar above grid showing today's configured hours with link to settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -266,12 +266,39 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
return res.send(twimlReply(text));
|
||||
};
|
||||
|
||||
// ── Stage: reminder_initial → send reminder greeting ─────────────────
|
||||
// ── Stage: reminder_initial → two messages: 1) AI intro, 2) intent response ──
|
||||
if (stage === "reminder_initial") {
|
||||
const rawGreeting = chatTemplates.reminderGreeting ||
|
||||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply your message at any time you need.`;
|
||||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7.`;
|
||||
const introText = applyOfficeName(rawGreeting, officeName);
|
||||
|
||||
return reply(applyOfficeName(rawGreeting, officeName), "greeted");
|
||||
// Use Google AI (LangGraph) to read the patient's reply and classify yes/no
|
||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||
const { reply: intentReply, intent } = await runReminderGraph(
|
||||
Body, aiSettings.apiKey, language, apptDatetime,
|
||||
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
|
||||
);
|
||||
|
||||
if (intentReply) {
|
||||
let nextStage: ConversationStage;
|
||||
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
||||
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
||||
else nextStage = "done";
|
||||
|
||||
// Send message 1 (AI intro) via REST API — queued FIRST in Twilio so it arrives first
|
||||
const twilioSettings = await storage.getTwilioSettings(patient.userId);
|
||||
if (twilioSettings) {
|
||||
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
|
||||
await client.messages.create({ body: introText, from: twilioSettings.phoneNumber, to: From });
|
||||
await saveOutbound(patient.id, introText);
|
||||
}
|
||||
|
||||
// Send message 2 (yes/no response) via TwiML — queued SECOND
|
||||
return reply(intentReply, nextStage);
|
||||
}
|
||||
|
||||
// No clear intent detected — send only the intro and wait for next reply
|
||||
return reply(introText, "greeted");
|
||||
}
|
||||
|
||||
// ── Stage: greeted → classify yes/no for appointment reminder ────────
|
||||
@@ -283,7 +310,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
);
|
||||
if (aiReply) {
|
||||
let nextStage: ConversationStage;
|
||||
if (intent === "no") nextStage = "asked_reschedule_confirm";
|
||||
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
||||
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
||||
else nextStage = "done";
|
||||
return reply(aiReply, nextStage);
|
||||
@@ -292,9 +319,10 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
|
||||
// ── Rescheduling flow stages ───────────────────────────────────────────
|
||||
const rescheduleStages: ConversationStage[] = [
|
||||
"asked_reschedule_confirm", "asked_reschedule_preference",
|
||||
"asked_reschedule_asap", "asked_reschedule_next_week",
|
||||
"asked_reschedule_time",
|
||||
"asked_reschedule_confirm", "asked_reschedule_preference",
|
||||
"asked_reschedule_asap", "asked_reschedule_next_week",
|
||||
"asked_reschedule_time", "asked_reschedule_datetime",
|
||||
"asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime",
|
||||
];
|
||||
if (rescheduleStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runRescheduleStep(
|
||||
@@ -412,6 +440,56 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
|
||||
// ── Stage: done → closing thank-you reply ────────────────────────────
|
||||
// When the patient sends a thank-you / acknowledgement after the conversation
|
||||
// is complete, reply warmly with their upcoming appointment time.
|
||||
if (stage === "done") {
|
||||
const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(Body);
|
||||
if (isThanks) {
|
||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||
const CLOSING: Record<string, string> = {
|
||||
English: apptDatetime
|
||||
? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.`
|
||||
: `Thank you for choosing our office! We look forward to seeing you soon.`,
|
||||
Spanish: apptDatetime
|
||||
? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.`
|
||||
: `¡Gracias por elegirnos! Le esperamos pronto.`,
|
||||
Portuguese: apptDatetime
|
||||
? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.`
|
||||
: `Obrigado por nos escolher! Aguardamos sua visita em breve.`,
|
||||
Mandarin: apptDatetime
|
||||
? `感谢您选择我们!期待在 ${apptDatetime} 见到您。`
|
||||
: `感谢您选择我们!期待很快见到您。`,
|
||||
Cantonese: apptDatetime
|
||||
? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。`
|
||||
: `感謝您選擇我們!期待很快見到您。`,
|
||||
Arabic: apptDatetime
|
||||
? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.`
|
||||
: `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`,
|
||||
"Haitian Creole": apptDatetime
|
||||
? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.`
|
||||
: `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`,
|
||||
};
|
||||
const fallback = CLOSING[language] ?? CLOSING["English"]!;
|
||||
if (aiSettings?.apiKey && apptDatetime) {
|
||||
try {
|
||||
const { ChatGoogleGenerativeAI } = await import("@langchain/google-genai");
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey: aiSettings.apiKey });
|
||||
const res = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a friendly dental office AI assistant. The patient just said "${Body}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`,
|
||||
},
|
||||
{ role: "user", content: Body },
|
||||
]);
|
||||
const aiMsg = String(res.content).trim();
|
||||
if (aiMsg) return reply(aiMsg, "done");
|
||||
} catch { /* fall through to fallback */ }
|
||||
}
|
||||
return reply(fallback, "done");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stage: initial (no active conversation) ───────────────────────────
|
||||
// Check after-hours: if enabled and currently outside office hours → start new-patient flow
|
||||
if (stage === "initial" || stage === "done") {
|
||||
|
||||
Reference in New Issue
Block a user