feat: add AI recall calling — books existing patients for routine cleanings

Adds a new recall conversation flow (recall-graph.ts) triggered from the
Patient Connection "AI Call" button: offers an existing patient a routine
exam & cleaning, classifies their yes/no reply, then books directly onto
the schedule (Column A, 30-minute slots, real conflict checking against
office hours and existing appointments) instead of only capturing a
preference for staff to follow up on.

Also adds a "Voice call" label in the message thread to distinguish call
transcripts from SMS, and updates the Call Templates settings UI with an
editable recall greeting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ff
2026-08-02 23:13:09 -04:00
parent 06dc63c53e
commit cef928be97
10 changed files with 588 additions and 39 deletions

View File

@@ -3,6 +3,9 @@ import { prisma as db } from "@repo/db/client";
export type ConversationStage = export type ConversationStage =
| "initial" | "initial"
| "reminder_initial" | "reminder_initial"
| "recall_initial"
| "recall_asked_datetime"
| "recall_asked_time_for_date"
| "greeted" | "greeted"
| "done" | "done"
| "new_patient_greeted" | "new_patient_greeted"
@@ -81,6 +84,12 @@ export async function startRescheduleConversation(userId: number, patientId: num
await setStage(userId, patientId, "asked_reschedule_confirm"); await setStage(userId, patientId, "asked_reschedule_confirm");
} }
// Recall: office proactively calls/texts an existing patient to offer a
// routine exam & cleaning (typically covered at no cost by insurance).
export async function startRecallConversation(userId: number, patientId: number): Promise<void> {
await setStage(userId, patientId, "recall_initial");
}
// ── Pending reschedule (in-memory — seconds-lived within a single exchange) ─── // ── Pending reschedule (in-memory — seconds-lived within a single exchange) ───
interface PendingReschedule { interface PendingReschedule {

View File

@@ -2,6 +2,7 @@ import twilio from "twilio";
import { prisma as db } from "@repo/db/client"; import { prisma as db } from "@repo/db/client";
import { storage } from "../storage"; import { storage } from "../storage";
import { runReminderGraph } from "./reminder-graph"; import { runReminderGraph } from "./reminder-graph";
import { runRecallGraph, runRecallBookingStep } from "./recall-graph";
import { runNewPatientStep } from "./new-patient-graph"; import { runNewPatientStep } from "./new-patient-graph";
import { import {
runRescheduleStep, runRescheduleStep,
@@ -273,6 +274,46 @@ export async function routePatientTurn(params: {
return finish(patient.userId, patient.id, [introText], "greeted"); return finish(patient.userId, patient.id, [introText], "greeted");
} }
// ── Stage: recall_initial → office proactively offered an existing patient
// a routine exam & cleaning (usually covered at no cost by insurance); the
// pitch itself was already spoken/sent as the opening greeting, so this
// just classifies the patient's yes/no. On yes, it books directly onto the
// schedule (Column A, 30-minute slots) via runRecallBookingStep — unlike
// reschedule, a recall patient has no existing appointment to move, so this
// is a real create-appointment path, not a shortcut into the reschedule flow.
if (stage === "recall_initial") {
const { reply: intentReply, intent } = await runRecallGraph(
message, activeAi.key, language, chatTemplates.generalFallback, activeAi.provider, activeAi.model
);
if (intentReply) {
if (intent === "yes") {
// Patient may have already given a date (or date + time) in this same
// reply ("Tuesday 1pm") — try to book right away instead of asking again.
const { reply: bookingReply, nextStage: bookingNextStage } = await runRecallBookingStep(
message, "recall_asked_datetime", language, patient.id, patient.userId, activeAi.key, activeAi.provider, activeAi.model
);
return finish(patient.userId, patient.id, [bookingReply], bookingNextStage);
}
if (intent === "no") {
return finish(patient.userId, patient.id, [intentReply], "done");
}
return finish(patient.userId, patient.id, [intentReply], "recall_initial");
}
return finish(patient.userId, patient.id, [chatTemplates.generalFallback || "Thank you for your message!"], "recall_initial");
}
// ── Recall booking flow — collecting/confirming the day & time, then
// creating the appointment (see recall-graph.ts: runRecallBookingStep) ──
const recallBookingStages: ConversationStage[] = ["recall_asked_datetime", "recall_asked_time_for_date"];
if (recallBookingStages.includes(stage)) {
const { reply: aiReply, nextStage } = await runRecallBookingStep(
message, stage, language, patient.id, patient.userId, activeAi.key, activeAi.provider, activeAi.model
);
return finish(patient.userId, patient.id, [aiReply], nextStage);
}
// ── Stage: greeted → classify yes/no for appointment reminder ────────── // ── Stage: greeted → classify yes/no for appointment reminder ──────────
if (stage === "greeted") { if (stage === "greeted") {
const apptDatetime = await getAppointmentDatetime(patient.id); const apptDatetime = await getAppointmentDatetime(patient.id);

View File

@@ -0,0 +1,450 @@
import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
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";
import {
parseDateOnlyFromMessage, parseTime, isOfficeDayOpen, isWithinOfficeHours,
getOfficeHoursDisplay, timeLabel,
} from "./reschedule-graph";
const GraphState = Annotation.Root({
message: Annotation<string>(),
intent: Annotation<string>(),
reply: Annotation<string>(),
language: Annotation<string>(),
generalFallback: Annotation<string>(),
});
type GraphStateType = typeof GraphState.State;
// ── Intent classifier — multilingual yes/no keywords (shared shape with reminder-graph) ──
function classifyNode(state: GraphStateType) {
const text = state.message.toLowerCase().trim();
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|sounds good|i'?d like (that|to)|let'?s do it|book it|schedule it|i want (an? )?appointment|i want to (come in|schedule|book)|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|de acuerdo|seguro|sim|com certeza|好的|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/;
const noPatterns = /\b(no|nope|not interested|not right now|no thanks|can't|cannot|won't|not now|not available|busy|sorry|unable|no puedo|no gracias|ahora no|não posso|agora não|não|占线|无法|不用了|不需要|لا|لا أستطيع|pa kapab|pa ka)\b/;
// A specific day/date/time, or an appointment-request phrase, is itself a
// strong signal the patient wants to book — real replies rarely say a bare
// "yes", they jump straight to "Tuesday at 1pm" or "I want an appointment".
const mentionsDateOrTime =
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(text) ||
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(text) ||
/\b\d{1,2}(:\d{2})?\s*(am|pm|a\.m\.|p\.m\.)\b/i.test(text);
const mentionsAppointment = /\b(appointment|schedule|book|come in|check.?up|cleaning)\b/i.test(text);
if (noPatterns.test(text) && !mentionsDateOrTime) return { intent: "no" };
if (yesPatterns.test(text) || mentionsDateOrTime || mentionsAppointment) return { intent: "yes" };
return { intent: "other" };
}
function routeByIntent(state: GraphStateType): string {
if (state.intent === "yes") return "wantsBooking";
if (state.intent === "no") return "declines";
return "other";
}
// ── Fallbacks ──────────────────────────────────────────────────────────────
const WANTS_BOOKING_FALLBACKS: Record<string, string> = {
English: "Great! What day works best for you?",
Spanish: "¡Genial! ¿Qué día le viene mejor?",
Portuguese: "Ótimo! Qual dia é melhor para você?",
Mandarin: "太好了!您哪天方便?",
Cantonese: "太好喇!您邊日方便?",
Arabic: "رائع! ما هو اليوم الأنسب لك؟",
"Haitian Creole": "Bon! Ki jou ki pi bon pou ou?",
};
const DECLINES_FALLBACKS: Record<string, string> = {
English: "No problem at all! Feel free to reach out whenever you're ready. Have a great day!",
Spanish: "¡No hay problema! No dude en contactarnos cuando esté listo. ¡Que tenga un buen día!",
Portuguese: "Sem problemas! Entre em contato sempre que estiver pronto. Tenha um ótimo dia!",
Mandarin: "没问题!您准备好了随时联系我们。祝您愉快!",
Cantonese: "冇問題!您準備好隨時聯絡我們。祝您愉快!",
Arabic: "لا مشكلة على الإطلاق! لا تتردد في التواصل معنا عندما تكون مستعداً. أتمنى لك يوماً سعيداً!",
"Haitian Creole": "Pa gen pwoblèm! Ou ka kontakte nou nenpòt lè ou pare. Pase yon bon jounen!",
};
const GENERAL_FALLBACKS: Record<string, string> = {
English: "Thank you for your message! Our office staff will be happy to assist you shortly.",
Spanish: "¡Gracias por su mensaje! El personal de nuestra oficina estará encantado de ayudarle en breve.",
Portuguese: "Obrigado pela sua mensagem! Nossa equipe terá prazer em ajudá-lo em breve.",
Mandarin: "感谢您的留言!我们的办公室工作人员将很快为您提供帮助。",
Cantonese: "感謝您的留言!我們的辦公室工作人員將很快為您提供幫助。",
Arabic: "شكراً على رسالتك! سيسعد فريق مكتبنا بمساعدتك قريباً.",
"Haitian Creole": "Mèsi pou mesaj ou! Ekip biwo nou an pral kontan ede ou byento.",
};
// ── LangGraph nodes ───────────────────────────────────────────────────────────
async function wantsBookingNode(state: GraphStateType, config: any) {
const apiKey: string | undefined = config?.configurable?.apiKey;
const lang = state.language || "English";
const fallback = WANTS_BOOKING_FALLBACKS[lang] ?? WANTS_BOOKING_FALLBACKS["English"]!;
if (!apiKey) return { reply: fallback };
try {
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
content:
`You are a friendly dental office assistant. The patient agreed to come in for a routine exam and cleaning. Write a short, warm reply (1-2 sentences max) and ask what day works best for them. Do NOT introduce yourself or say your name — the introduction has already been sent in a separate message. You MUST reply in ${lang}. No formatting, no extra text.`,
},
{ role: "user", content: `Patient replied: "${state.message}"` },
]);
return { reply: String(response.content) || fallback };
} catch {
return { reply: fallback };
}
}
async function declinesNode(state: GraphStateType, config: any) {
const apiKey: string | undefined = config?.configurable?.apiKey;
const lang = state.language || "English";
const fallback = DECLINES_FALLBACKS[lang] ?? DECLINES_FALLBACKS["English"]!;
if (!apiKey) return { reply: fallback };
try {
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
content:
`You are a friendly dental office assistant. The patient does not want to schedule a checkup right now. Write a short, polite reply (1-2 sentences max) letting them know that's no problem and they can reach out whenever they're ready. Do NOT introduce yourself or say your name. You MUST reply in ${lang}. No formatting, no extra text.`,
},
{ role: "user", content: `Patient replied: "${state.message}"` },
]);
return { reply: String(response.content) || fallback };
} catch {
return { reply: fallback };
}
}
async function otherNode(state: GraphStateType, config: any) {
const apiKey: string | undefined = config?.configurable?.apiKey;
const lang = state.language || "English";
const fallback = state.generalFallback || (GENERAL_FALLBACKS[lang] ?? GENERAL_FALLBACKS["English"]!);
if (!apiKey) return { reply: fallback };
try {
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
content: `You are a friendly dental office AI assistant. Respond helpfully to the patient's message in ${lang}. Keep it to 1-2 sentences, no formatting. For non-dental questions, let them know our office staff can assist.`,
},
{ role: "user", content: `Patient said: "${state.message}"` },
]);
return { reply: String(response.content) || fallback };
} catch {
return { reply: fallback };
}
}
// ── Graph ─────────────────────────────────────────────────────────────────────
const graph = new StateGraph(GraphState)
.addNode("classify", classifyNode)
.addNode("wantsBooking", wantsBookingNode)
.addNode("declines", declinesNode)
.addNode("other", otherNode)
.addEdge(START, "classify")
.addConditionalEdges("classify", routeByIntent, {
wantsBooking: "wantsBooking",
declines: "declines",
other: "other",
})
.addEdge("wantsBooking", END)
.addEdge("declines", END)
.addEdge("other", END)
.compile();
export async function runRecallGraph(
patientMessage: string,
apiKey: string,
language = "English",
generalFallback = "",
provider: AiProvider = "google",
model?: string
): Promise<{ reply: string | null; intent: string | null }> {
const result = await graph.invoke(
{ message: patientMessage, intent: "", reply: "", language, generalFallback },
{ configurable: { apiKey, provider, model } }
);
return {
reply: result.reply || null,
intent: result.intent || null,
};
}
// ── Recall booking — actually reserves a slot, unlike the reschedule flow ──
// (which only MOVES an existing appointment). A recall patient has none, so
// this creates a new one directly on "Column A" (the office's first staff
// column, matching the convention already used in internal-chat-workflow.ts),
// in fixed 30-minute slots.
const RECALL_SLOT_MINUTES = 30;
function toMinutes(t: string): number {
const [h, m] = t.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
function fromMinutes(m: number): string {
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
}
/** "Column A" = the office's first staff record by id, per the same convention
* used for AI-booked appointments elsewhere (see internal-chat-workflow.ts). */
async function getColumnAStaffId(userId: number): Promise<number | null> {
const staff = await db.staff.findFirst({ where: { userId }, orderBy: { id: "asc" } });
return staff?.id ?? null;
}
/**
* Finds a free RECALL_SLOT_MINUTES slot for `staffId` on `date`. Prefers
* `requestedStartTime` if it's free; otherwise falls back to the earliest
* open slot that day. Returns null if the whole day is fully booked.
*/
async function findRecallSlot(
userId: number, staffId: number, date: Date, requestedStartTime?: string | null,
): Promise<{ time: string; matchedRequest: boolean } | null> {
const officeHours = await storage.getOfficeHours(userId);
const data = (officeHours as any)?.data;
const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
const dayName = dayNames[date.getUTCDay()]!;
// Union of doctors' + hygienists' windows that day — matches the "open if
// within either group's hours" tolerance isWithinOfficeHours() already uses.
const windows: [number, number][] = [];
for (const group of [data?.doctors, data?.hygienists]) {
const slot = group?.[dayName];
if (slot?.enabled) {
windows.push([toMinutes(slot.amStart), toMinutes(slot.amEnd)]);
windows.push([toMinutes(slot.pmStart), toMinutes(slot.pmEnd)]);
}
}
if (windows.length === 0) {
windows.push([toMinutes("09:00"), toMinutes("12:00")], [toMinutes("13:00"), toMinutes("17:00")]);
}
const allSlots = new Set<number>();
for (const [start, end] of windows) {
for (let t = start; t + RECALL_SLOT_MINUTES <= end; t += RECALL_SLOT_MINUTES) allSlots.add(t);
}
const dateStr = date.toISOString().split("T")[0]!;
const existing = await storage.getAppointmentsByDateForUser(dateStr, userId);
const booked = existing
.filter((a: any) => a.staffId === staffId && a.status !== "cancelled")
.map((a: any) => ({ start: toMinutes(a.startTime), end: toMinutes(a.endTime) }));
const isFree = (slotStart: number) => {
const slotEnd = slotStart + RECALL_SLOT_MINUTES;
return !booked.some((b) => slotStart < b.end && slotEnd > b.start);
};
if (requestedStartTime) {
const reqStart = toMinutes(requestedStartTime);
if (allSlots.has(reqStart) && isFree(reqStart)) return { time: requestedStartTime, matchedRequest: true };
}
const sorted = Array.from(allSlots).sort((a, b) => a - b);
const fallback = sorted.find(isFree);
return fallback === undefined ? null : { time: fromMinutes(fallback), matchedRequest: false };
}
const DAY_FULL_FALLBACKS: Record<string, (dateLabel: string) => string> = {
English: (d) => `I'm sorry, ${d} is fully booked. Could you please choose another day?`,
Spanish: (d) => `Lo sentimos, el ${d} ya está completo. ¿Podría elegir otro día?`,
Portuguese: (d) => `Sinto muito, ${d} está totalmente reservado. Poderia escolher outro dia?`,
Mandarin: (d) => `很抱歉,${d} 的预约已满。您能选择其他日期吗?`,
Cantonese: (d) => `好抱歉,${d} 已經約滿。您可以選擇其他日子嗎?`,
Arabic: (d) => `عذراً، ${d} محجوز بالكامل. هل يمكنك اختيار يوم آخر؟`,
"Haitian Creole": (d) => `Padon, ${d} konplè. Èske ou ka chwazi yon lòt jou?`,
};
const NO_STAFF_FALLBACK: Record<string, string> = {
English: "Thanks! Our office staff will reach out shortly to confirm a time that works.",
Spanish: "¡Gracias! Nuestro personal se pondrá en contacto en breve para confirmar un horario.",
Portuguese: "Obrigado! Nossa equipe entrará em contato em breve para confirmar um horário.",
Mandarin: "谢谢!我们的工作人员将很快与您联系以确认时间。",
Cantonese: "多謝!我們的職員好快會聯絡您確認時間。",
Arabic: "شكراً! سيتواصل معك فريقنا قريباً لتأكيد الموعد.",
"Haitian Creole": "Mèsi! Ekip biwo nou an pral kontakte ou byento pou konfime yon lè.",
};
const BOOKED_EXACT_FALLBACKS: Record<string, (dateLabel: string, time: string) => string> = {
English: (d, t) => `You're all set for ${d} at ${t}! We look forward to seeing you.`,
Spanish: (d, t) => `¡Todo listo para el ${d} a las ${t}! Le esperamos.`,
Portuguese: (d, t) => `Tudo pronto para ${d} às ${t}! Aguardamos sua visita.`,
Mandarin: (d, t) => `已为您安排在 ${d} ${t}!期待您的光临。`,
Cantonese: (d, t) => `已幫您安排喺 ${d} ${t}!期待見到您。`,
Arabic: (d, t) => `تم الحجز في ${d} الساعة ${t}! نتطلع لرؤيتك.`,
"Haitian Creole": (d, t) => `Ou pare pou ${d} a ${t}! N'ap tann ou.`,
};
const BOOKED_ALT_FALLBACKS: Record<string, (dateLabel: string, time: string) => string> = {
English: (d, t) => `That exact time was just taken, so I booked you for ${t} on ${d} instead. We look forward to seeing you!`,
Spanish: (d, t) => `Esa hora ya no estaba disponible, así que le reservé a las ${t} el ${d}. ¡Le esperamos!`,
Portuguese: (d, t) => `Esse horário exato já foi reservado, então marquei ${t} em ${d}. Aguardamos sua visita!`,
Mandarin: (d, t) => `那个时间刚被预订了,我已为您安排在 ${d} ${t}。期待您的光临!`,
Cantonese: (d, t) => `嗰個時間啱啱被人訂咗,我已經幫您安排喺 ${d} ${t}。期待見到您!`,
Arabic: (d, t) => `تم حجز ذلك الوقت للتو، لذا حجزت لك موعداً في ${d} الساعة ${t}. نتطلع لرؤيتك!`,
"Haitian Creole": (d, t) => `Lè sa a fèk pran, kidonk mwen bwouke ou pou ${t} nan ${d}. N'ap tann ou!`,
};
const ASK_TIME_FALLBACKS: Record<string, (dateLabel: string) => string> = {
English: (d) => `Great, what time on ${d} works for you?`,
Spanish: (d) => `Genial, ¿qué hora le viene bien el ${d}?`,
Portuguese: (d) => `Ótimo, que horário funciona para você em ${d}?`,
Mandarin: (d) => `太好了,您 ${d} 哪个时间方便?`,
Cantonese: (d) => `太好喇,您 ${d} 邊個時間方便?`,
Arabic: (d) => `رائع، ما الوقت المناسب لك في ${d}؟`,
"Haitian Creole": (d) => `Bon, ki lè nan ${d} ki bon pou ou?`,
};
const CLOSED_DAY_FALLBACKS: Record<string, (day: string) => string> = {
English: (day) => `We're closed on ${day}s. What other day works for you?`,
Spanish: (day) => `Estamos cerrados los ${day}. ¿Qué otro día le viene bien?`,
Portuguese: (day) => `Estamos fechados aos ${day}. Que outro dia funciona para você?`,
Mandarin: (day) => `我们${day}休息。您方便其他哪天?`,
Cantonese: (day) => `我們${day}休息。您方便其他邊日?`,
Arabic: (day) => `نحن مغلقون أيام ${day}. ما يوم آخر يناسبك؟`,
"Haitian Creole": (day) => `Nou fèmen jou ${day}. Ki lòt jou ki bon pou ou?`,
};
const NO_DATE_FALLBACKS: Record<string, string> = {
English: "Sorry, I didn't catch a day — what day would you like to come in?",
Spanish: "Disculpe, no entendí el día — ¿qué día le gustaría venir?",
Portuguese: "Desculpe, não entendi o dia — que dia você gostaria de vir?",
Mandarin: "抱歉,我没听清日期——您想哪天过来?",
Cantonese: "抱歉,我冇聽清楚日期——您想邊日嚟?",
Arabic: "عذراً، لم أفهم اليوم — ما اليوم الذي تود الحضور فيه؟",
"Haitian Creole": "Padon, mwen pa konprann jou a — ki jou ou ta renmen vini?",
};
/** Books the recall appointment (or reports the day is full) and returns the reply + next stage. */
async function bookRecallSlot(
patientId: number, userId: number, date: Date, dateLabel: string, requestedStartTime: string | null, lang: string,
): Promise<{ reply: string; nextStage: ConversationStage }> {
const staffId = await getColumnAStaffId(userId);
if (staffId === null) {
return { reply: NO_STAFF_FALLBACK[lang] ?? NO_STAFF_FALLBACK["English"]!, nextStage: "done" };
}
const slot = await findRecallSlot(userId, staffId, date, requestedStartTime);
if (!slot) {
const build = DAY_FULL_FALLBACKS[lang] ?? DAY_FULL_FALLBACKS["English"]!;
return { reply: build(dateLabel), nextStage: "recall_asked_datetime" };
}
const endTime = fromMinutes(toMinutes(slot.time) + RECALL_SLOT_MINUTES);
await storage.createAppointment({
patientId, userId, staffId,
title: "Routine Exam & Cleaning",
date, startTime: slot.time, endTime,
type: "recall",
status: "scheduled",
movedByAi: true,
} as any);
const timeLbl = timeLabel(slot.time);
const build = slot.matchedRequest
? (BOOKED_EXACT_FALLBACKS[lang] ?? BOOKED_EXACT_FALLBACKS["English"]!)
: (BOOKED_ALT_FALLBACKS[lang] ?? BOOKED_ALT_FALLBACKS["English"]!);
return { reply: build(dateLabel, timeLbl), nextStage: "done" };
}
/**
* Drives the recall_asked_datetime / recall_asked_time_for_date stages:
* parses a date (and optionally a time) out of the patient's reply, checks
* office hours, then books via bookRecallSlot() once both pieces are known.
*/
export async function runRecallBookingStep(
message: string,
stage: ConversationStage,
language: string,
patientId: number,
userId: number,
apiKey: string,
provider: AiProvider = "google",
model?: string
): Promise<{ reply: string; nextStage: ConversationStage }> {
const lang = language || "English";
// ── Waiting on a time only (date was already captured last turn) ─────────
if (stage === "recall_asked_time_for_date") {
const pending = getPendingReschedule(userId, patientId);
if (!pending) {
return { reply: NO_DATE_FALLBACKS[lang] ?? NO_DATE_FALLBACKS["English"]!, nextStage: "recall_asked_datetime" };
}
const time = await parseTime(message, apiKey, provider, model);
if (!time) {
const build = ASK_TIME_FALLBACKS[lang] ?? ASK_TIME_FALLBACKS["English"]!;
return { reply: build(pending.dayLabel), nextStage: "recall_asked_time_for_date" };
}
return await resolveDateTime(patientId, userId, pending.newDate, pending.dayLabel, time, lang);
}
// ── recall_initial (yes, with a date already in the same message) or
// recall_asked_datetime — parse a date out of the message ────────────────
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
if (!parsedDate) {
return { reply: NO_DATE_FALLBACKS[lang] ?? NO_DATE_FALLBACKS["English"]!, nextStage: "recall_asked_datetime" };
}
const dayCheck = await isOfficeDayOpen(parsedDate.date, userId);
if (!dayCheck.open) {
const build = CLOSED_DAY_FALLBACKS[lang] ?? CLOSED_DAY_FALLBACKS["English"]!;
return { reply: build(dayCheck.displayDay), nextStage: "recall_asked_datetime" };
}
const time = await parseTime(message, apiKey, provider, model);
if (!time) {
setPendingReschedule(userId, patientId, { newDate: parsedDate.date, dayLabel: parsedDate.dateLabel });
const build = ASK_TIME_FALLBACKS[lang] ?? ASK_TIME_FALLBACKS["English"]!;
return { reply: build(parsedDate.dateLabel), nextStage: "recall_asked_time_for_date" };
}
return await resolveDateTime(patientId, userId, parsedDate.date, parsedDate.dateLabel, time, lang);
}
async function resolveDateTime(
patientId: number, userId: number, date: Date, dateLabel: string, time: string, lang: string,
): Promise<{ reply: string; nextStage: ConversationStage }> {
const withinHours = await isWithinOfficeHours(date, time, userId);
if (!withinHours) {
const hoursDisplay = await getOfficeHoursDisplay(date, userId);
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel });
const base: Record<string, string> = {
English: `We're not available at that time on ${dateLabel}.`,
Spanish: `No estamos disponibles a esa hora el ${dateLabel}.`,
Portuguese: `Não estamos disponíveis nesse horário em ${dateLabel}.`,
Mandarin: `我们在 ${dateLabel} 的那个时间不营业。`,
Cantonese: `我們喺 ${dateLabel} 嗰個時間唔開放。`,
Arabic: `نحن غير متاحين في ذلك الوقت يوم ${dateLabel}.`,
"Haitian Creole": `Nou pa disponib nan lè sa a nan ${dateLabel}.`,
};
const askTime = (ASK_TIME_FALLBACKS[lang] ?? ASK_TIME_FALLBACKS["English"]!)(dateLabel);
const hours = hoursDisplay ? ` ${hoursDisplay}.` : "";
return { reply: `${base[lang] ?? base["English"]}${hours} ${askTime}`, nextStage: "recall_asked_time_for_date" };
}
clearPendingReschedule(userId, patientId);
return await bookRecallSlot(patientId, userId, date, dateLabel, time, lang);
}

View File

@@ -240,8 +240,8 @@ router.put("/call-templates", async (req: Request, res: Response): Promise<any>
try { try {
const userId = req.user?.id; const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" }); if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { greeting, reminderGreeting, newPatientGreeting, generalFallback } = req.body; const { greeting, reminderGreeting, newPatientGreeting, generalFallback, recallGreeting } = req.body;
await storage.saveAiCallTemplates(userId, { greeting, reminderGreeting, newPatientGreeting, generalFallback }); await storage.saveAiCallTemplates(userId, { greeting, reminderGreeting, newPatientGreeting, generalFallback, recallGreeting });
const updated = await storage.getAiCallTemplates(userId); const updated = await storage.getAiCallTemplates(userId);
return res.status(200).json(updated); return res.status(200).json(updated);
} catch (err) { } catch (err) {

View File

@@ -19,7 +19,7 @@ import {
} from "../ai/patient-conversation-router"; } from "../ai/patient-conversation-router";
import { import {
getHandoff, getAfterHoursHandoff, getHandoff, getAfterHoursHandoff,
getStage, setStage, resetConversation, getStage, setStage, resetConversation, startRecallConversation,
setPendingReschedule, getPendingReschedule, clearPendingReschedule, setPendingReschedule, getPendingReschedule, clearPendingReschedule,
type ConversationStage, type ConversationStage,
} from "../ai/aiHandoffStore"; } from "../ai/aiHandoffStore";
@@ -696,6 +696,7 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
try { try {
const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string }; const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string };
const patientIdParam = req.query.patientId as string | undefined; const patientIdParam = req.query.patientId as string | undefined;
const purposeParam = req.query.purpose as string | undefined;
if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye.")); if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye."));
let session = aiCallSessions.get(CallSid); let session = aiCallSessions.get(CallSid);
@@ -713,13 +714,17 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
const language = patient.preferredLanguage || "English"; const language = patient.preferredLanguage || "English";
const locale = voiceLocaleFor(language); const locale = voiceLocaleFor(language);
const isRecallCall = purposeParam === "recall";
// Resume an in-progress cross-channel conversation (e.g. mid-reschedule // Resume an in-progress cross-channel conversation (e.g. mid-reschedule
// from a prior text) if one exists; otherwise start a fresh reminder-style // from a prior text) if one exists; otherwise start a fresh check-in call.
// check-in call, matching the default the reminder-SMS batch send uses. // A "recall" call (staff-triggered from Patient Connection, offering an
// existing patient a routine exam & cleaning) seeds a different stage
// than the default reminder-style check-in.
const currentStage = await getStage(patient.userId, patient.id); const currentStage = await getStage(patient.userId, patient.id);
if (currentStage === "initial" || currentStage === "done") { if (currentStage === "initial" || currentStage === "done") {
await resetConversation(patient.userId, patient.id); if (isRecallCall) await startRecallConversation(patient.userId, patient.id);
else await resetConversation(patient.userId, patient.id);
} }
const officeContact = await storage.getOfficeContact(patient.userId); const officeContact = await storage.getOfficeContact(patient.userId);
@@ -732,7 +737,7 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
}; };
aiCallSessions.set(CallSid, session); aiCallSessions.set(CallSid, session);
const rawCallTemplate = callTemplates.greeting?.trim() || const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) ||
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?"; "Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?";
const greeting = rawCallTemplate const greeting = rawCallTemplate
.replace(/\{firstName\}/g, patient.firstName || "there") .replace(/\{firstName\}/g, patient.firstName || "there")

View File

@@ -127,7 +127,7 @@ router.post("/make-ai-call", async (req: Request, res: Response): Promise<any> =
const userId = req.user?.id; const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" }); if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { to, patientId } = req.body; const { to, patientId, purpose } = req.body;
if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" }); if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" });
const settings = await storage.getTwilioSettings(userId); const settings = await storage.getTwilioSettings(userId);
@@ -149,7 +149,8 @@ router.post("/make-ai-call", async (req: Request, res: Response): Promise<any> =
} catch (err: any) { } catch (err: any) {
return res.status(500).json({ message: err.message }); return res.status(500).json({ message: err.message });
} }
const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}`; const callPurpose = purpose === "recall" ? "recall" : "reminder";
const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}&purpose=${callPurpose}`;
const client = getTwilioClient(settings.accountSid, settings.authToken); const client = getTwilioClient(settings.accountSid, settings.authToken);
const call = await client.calls.create({ const call = await client.calls.create({

View File

@@ -109,10 +109,12 @@ export const twilioStorage = {
reminderGreeting: all["_ai_call_reminder_greeting"] ?? "Sure, I can help with that.", reminderGreeting: all["_ai_call_reminder_greeting"] ?? "Sure, I can help with that.",
newPatientGreeting: all["_ai_call_new_patient_greeting"] ?? "No problem, let's get you set up.", newPatientGreeting: all["_ai_call_new_patient_greeting"] ?? "No problem, let's get you set up.",
generalFallback: all["_ai_call_general_fallback"] ?? "Sure, how can I help?", generalFallback: all["_ai_call_general_fallback"] ?? "Sure, how can I help?",
recallGreeting: all["_ai_call_recall_greeting"] ??
"Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning, which most insurance plans cover at no cost. Would you like to schedule one?",
}; };
}, },
async saveAiCallTemplates(userId: number, templates: { greeting?: string; reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string }) { async saveAiCallTemplates(userId: number, templates: { greeting?: string; reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; recallGreeting?: string }) {
const settings = await db.twilioSettings.findUnique({ where: { userId } }); const settings = await db.twilioSettings.findUnique({ where: { userId } });
const existing = (settings?.templates as Record<string, string>) || {}; const existing = (settings?.templates as Record<string, string>) || {};
const updated: Record<string, string> = { ...existing }; const updated: Record<string, string> = { ...existing };
@@ -120,6 +122,7 @@ export const twilioStorage = {
if (templates.reminderGreeting !== undefined) updated["_ai_call_reminder_greeting"] = templates.reminderGreeting; if (templates.reminderGreeting !== undefined) updated["_ai_call_reminder_greeting"] = templates.reminderGreeting;
if (templates.newPatientGreeting !== undefined) updated["_ai_call_new_patient_greeting"] = templates.newPatientGreeting; if (templates.newPatientGreeting !== undefined) updated["_ai_call_new_patient_greeting"] = templates.newPatientGreeting;
if (templates.generalFallback !== undefined) updated["_ai_call_general_fallback"] = templates.generalFallback; if (templates.generalFallback !== undefined) updated["_ai_call_general_fallback"] = templates.generalFallback;
if (templates.recallGreeting !== undefined) updated["_ai_call_recall_greeting"] = templates.recallGreeting;
return db.twilioSettings.upsert({ return db.twilioSettings.upsert({
where: { userId }, where: { userId },
update: { templates: updated }, update: { templates: updated },

View File

@@ -11,7 +11,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX } from "lucide-react"; import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX, Phone } from "lucide-react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { Patient, Communication } from "@repo/db/types"; import type { Patient, Communication } from "@repo/db/types";
import { format, isToday, isYesterday, parseISO } from "date-fns"; import { format, isToday, isYesterday, parseISO } from "date-fns";
@@ -534,6 +534,12 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
{patient.firstName[0]}{patient.lastName[0]} {patient.firstName[0]}{patient.lastName[0]}
</div> </div>
<div> <div>
{comm.channel === "voice" && (
<div className="flex items-center gap-1 mb-1 text-[10px] font-medium text-muted-foreground">
<Phone className="h-3 w-3" />
Voice call
</div>
)}
<div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md"> <div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p> <p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div> </div>
@@ -545,6 +551,12 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
)} )}
{comm.direction === "outbound" && ( {comm.direction === "outbound" && (
<div> <div>
{comm.channel === "voice" && (
<div className="flex items-center justify-end gap-1 mb-1 text-[10px] font-medium text-muted-foreground">
<Phone className="h-3 w-3" />
Voice call
</div>
)}
<div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md"> <div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p> <p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div> </div>

View File

@@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall } from "lucide-react"; import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall, Stethoscope } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -25,6 +25,7 @@ type AiCallTemplates = {
reminderGreeting: string; reminderGreeting: string;
newPatientGreeting: string; newPatientGreeting: string;
generalFallback: string; generalFallback: string;
recallGreeting: string;
}; };
type OfficeContact = { type OfficeContact = {
@@ -51,6 +52,8 @@ const CALL_DEFAULTS = {
reminderGreeting: "Sure, I can help with that.", reminderGreeting: "Sure, I can help with that.",
newPatientGreeting: "No problem, let's get you set up.", newPatientGreeting: "No problem, let's get you set up.",
generalFallback: "Sure, how can I help?", generalFallback: "Sure, how can I help?",
recallGreeting:
"Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning, which most insurance plans cover at no cost. Would you like to schedule one?",
}; };
const DEFAULT_SMS_TEMPLATES = [ const DEFAULT_SMS_TEMPLATES = [
@@ -82,18 +85,19 @@ function LangGraphFlow() {
const nW = 210; const nW = 210;
const nx = cx - nW / 2; // 245 const nx = cx - nW / 2; // 245
// ── Dual entry nodes ───────────────────────────────────────────────────── // ── Triple entry nodes ─────────────────────────────────────────────────────
const entryW = 192; const entryW = 150;
const lEntryCx = 155; const lEntryCx = 130;
const rEntryCx = 545; const mEntryCx = cx; // 350
const e1y = 14, e1h = 56; const rEntryCx = 570;
const e1y = 14, e1h = 70;
// Merge connector (horizontal line where both branches meet) // Merge connector (horizontal line where all three entries meet)
const mergeY = e1y + e1h + 14; // 84 const mergeY = e1y + e1h + 14; // 98
// ── Center sequence ─────────────────────────────────────────────────────── // ── Center sequence ───────────────────────────────────────────────────────
const n2y = mergeY + 14, n2h = 52; // Patient replies y=98 const n2y = mergeY + 14, n2h = 52; // Patient replies
const n3y = n2y + n2h + 14, n3h = 84; // AI classifies y=164 const n3y = n2y + n2h + 14, n3h = 98; // AI classifies
const forkHY = n3y + n3h + 22; // 270 const forkHY = n3y + n3h + 22; // 270
@@ -103,7 +107,7 @@ function LangGraphFlow() {
// ── YES branch ──────────────────────────────────────────────────────────── // ── YES branch ────────────────────────────────────────────────────────────
const yesW = 195; const yesW = 195;
const yes1y = forkHY + 50, yes1h = 88; // y=320 const yes1y = forkHY + 50, yes1h = 102; // y=320
const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460 const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460
// ── NO / Reschedule branch ──────────────────────────────────────────────── // ── NO / Reschedule branch ────────────────────────────────────────────────
@@ -128,7 +132,7 @@ function LangGraphFlow() {
viewBox={`0 0 ${W} ${totalH}`} viewBox={`0 0 ${W} ${totalH}`}
className="w-full max-w-2xl mx-auto" className="w-full max-w-2xl mx-auto"
role="img" role="img"
aria-label="LangGraph Reminder and Reschedule conversation flow" aria-label="LangGraph Reminder, Recall, and Reschedule conversation flow"
> >
<defs> <defs>
<marker id="rg2ah" markerWidth="10" markerHeight="7" <marker id="rg2ah" markerWidth="10" markerHeight="7"
@@ -145,20 +149,29 @@ function LangGraphFlow() {
</marker> </marker>
</defs> </defs>
{/* ══ DUAL ENTRY NODES ════════════════════════════════════════════ */} {/* ══ TRIPLE ENTRY NODES ════════════════════════════════════════════ */}
{/* Left: Reminder SMS */} {/* Left: Reminder SMS */}
<rect x={lEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} /> <rect x={lEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
<text x={lEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Office sends Reminder SMS</text> <text x={lEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Office sends Reminder</text>
<text x={lEntryCx} y={e1y+40} textAnchor="middle" fontSize={9} fill="#93C5FD">Staff triggers batch send</text> <text x={lEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">SMS or Call</text>
<text x={lEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#93C5FD">Staff triggers batch send</text>
{/* Middle: Recall call/SMS for an existing patient */}
<rect x={mEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
<text x={mEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#5B21B6">Office call or SMS for</text>
<text x={mEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#5B21B6">an existing patient</text>
<text x={mEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#C4B5FD">Recall: free exam &amp; cleaning</text>
{/* Right: Reschedule by Office SMS */} {/* Right: Reschedule by Office SMS */}
<rect x={rEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} /> <rect x={rEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={rEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Office sends Reschedule SMS</text> <text x={rEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Office sends Reschedule</text>
<text x={rEntryCx} y={e1y+40} textAnchor="middle" fontSize={9} fill="#FDBA74">"Reschedule by Office" template</text> <text x={rEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">SMS</text>
<text x={rEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#FDBA74">"Reschedule by Office" template</text>
{/* Converging lines → horizontal merge → Patient replies */} {/* Converging lines → horizontal merge → Patient replies */}
<line x1={lEntryCx} y1={e1y+e1h} x2={lEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} /> <line x1={lEntryCx} y1={e1y+e1h} x2={lEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={mEntryCx} y1={e1y+e1h} x2={mEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={rEntryCx} y1={e1y+e1h} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} /> <line x1={rEntryCx} y1={e1y+e1h} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lEntryCx} y1={mergeY} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} /> <line x1={lEntryCx} y1={mergeY} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={cx} y1={mergeY} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" /> <line x1={cx} y1={mergeY} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
@@ -173,10 +186,11 @@ function LangGraphFlow() {
{/* N3: AI classifies */} {/* N3: AI classifies */}
<rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} /> <rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={cx} y={n3y+20} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text> <text x={cx} y={n3y+18} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text>
<text x={cx} y={n3y+36} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Reminder: intro (MSG 1) + reply (MSG 2)</text> <text x={cx} y={n3y+33} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Reminder: intro (MSG 1) + reply (MSG 2)</text>
<text x={cx} y={n3y+52} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: reply sent directly</text> <text x={cx} y={n3y+48} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: reply sent directly</text>
<text x={cx} y={n3y+70} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Date in reply AI skips to Day Check</text> <text x={cx} y={n3y+63} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: same as Reschedule flow</text>
<text x={cx} y={n3y+82} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Date in reply AI skips to Day Check</text>
{/* Fork lines */} {/* Fork lines */}
<line x1={cx} y1={n3y+n3h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} /> <line x1={cx} y1={n3y+n3h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
@@ -193,10 +207,11 @@ function LangGraphFlow() {
{/* ══ YES BRANCH ══════════════════════════════════════════════════ */} {/* ══ YES BRANCH ══════════════════════════════════════════════════ */}
<rect x={lcx-yesW/2} y={yes1y} width={yesW} height={yes1h} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} /> <rect x={lcx-yesW/2} y={yes1y} width={yesW} height={yes1h} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={lcx} y={yes1y+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Reminder: Thank you for confirming!</text> <text x={lcx} y={yes1y+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Reminder: Thank you for confirming!</text>
<text x={lcx} y={yes1y+34} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day &amp; time?</text> <text x={lcx} y={yes1y+31} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day &amp; time?</text>
<text x={lcx} y={yes1y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date &amp; time]"</text> <text x={lcx} y={yes1y+46} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: What day &amp; time?</text>
<text x={lcx} y={yes1y+70} textAnchor="middle" fontSize={8} fill="#047857">date in reply Day Check </text> <text x={lcx} y={yes1y+64} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date &amp; time]"</text>
<text x={lcx} y={yes1y+80} textAnchor="middle" fontSize={8} fill="#047857">date in reply Day Check </text>
<line x1={lcx} y1={yes1y+yes1h} x2={lcx} y2={yes2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" /> <line x1={lcx} y1={yes1y+yes1h} x2={lcx} y2={yes2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* Yes2: Patient thanks */} {/* Yes2: Patient thanks */}
@@ -206,7 +221,7 @@ function LangGraphFlow() {
{/* Dashed shortcut: YES with date → Day Check */} {/* Dashed shortcut: YES with date → Day Check */}
<line <line
x1={lcx + yesW/2} y1={yes1y + 52} x1={lcx + yesW/2} y1={yes1y + 64}
x2={rcx - noW/2 - 2} y2={no2y + 34} x2={rcx - noW/2 - 2} y2={no2y + 34}
stroke="#047857" strokeWidth={1} strokeDasharray="4 3" stroke="#047857" strokeWidth={1} strokeDasharray="4 3"
markerEnd="url(#rg2short)" markerEnd="url(#rg2short)"
@@ -1030,6 +1045,7 @@ export function AiChatSettingsCard() {
const [callReminderGreeting, setCallReminderGreeting] = useState(CALL_DEFAULTS.reminderGreeting); const [callReminderGreeting, setCallReminderGreeting] = useState(CALL_DEFAULTS.reminderGreeting);
const [callNewPatientGreeting, setCallNewPatientGreeting] = useState(CALL_DEFAULTS.newPatientGreeting); const [callNewPatientGreeting, setCallNewPatientGreeting] = useState(CALL_DEFAULTS.newPatientGreeting);
const [callGeneralFallback, setCallGeneralFallback] = useState(CALL_DEFAULTS.generalFallback); const [callGeneralFallback, setCallGeneralFallback] = useState(CALL_DEFAULTS.generalFallback);
const [callRecallGreeting, setCallRecallGreeting] = useState(CALL_DEFAULTS.recallGreeting);
const callInitialized = useRef(false); const callInitialized = useRef(false);
const [openPhoneReply, setOpenPhoneReply] = useState(false); const [openPhoneReply, setOpenPhoneReply] = useState(false);
@@ -1130,6 +1146,7 @@ export function AiChatSettingsCard() {
setCallReminderGreeting(callTemplatesData.reminderGreeting || CALL_DEFAULTS.reminderGreeting); setCallReminderGreeting(callTemplatesData.reminderGreeting || CALL_DEFAULTS.reminderGreeting);
setCallNewPatientGreeting(callTemplatesData.newPatientGreeting || CALL_DEFAULTS.newPatientGreeting); setCallNewPatientGreeting(callTemplatesData.newPatientGreeting || CALL_DEFAULTS.newPatientGreeting);
setCallGeneralFallback(callTemplatesData.generalFallback || CALL_DEFAULTS.generalFallback); setCallGeneralFallback(callTemplatesData.generalFallback || CALL_DEFAULTS.generalFallback);
setCallRecallGreeting(callTemplatesData.recallGreeting || CALL_DEFAULTS.recallGreeting);
} }
}, [callTemplatesData]); }, [callTemplatesData]);
@@ -1215,6 +1232,7 @@ export function AiChatSettingsCard() {
reminderGreeting: callReminderGreeting.trim() || CALL_DEFAULTS.reminderGreeting, reminderGreeting: callReminderGreeting.trim() || CALL_DEFAULTS.reminderGreeting,
newPatientGreeting: callNewPatientGreeting.trim() || CALL_DEFAULTS.newPatientGreeting, newPatientGreeting: callNewPatientGreeting.trim() || CALL_DEFAULTS.newPatientGreeting,
generalFallback: callGeneralFallback.trim() || CALL_DEFAULTS.generalFallback, generalFallback: callGeneralFallback.trim() || CALL_DEFAULTS.generalFallback,
recallGreeting: callRecallGreeting.trim() || CALL_DEFAULTS.recallGreeting,
}); });
}; };
@@ -1268,12 +1286,21 @@ export function AiChatSettingsCard() {
{ {
key: "callGreeting", key: "callGreeting",
icon: <PhoneCall className="h-4 w-4 text-primary" />, icon: <PhoneCall className="h-4 w-4 text-primary" />,
label: "Call Opening Greeting", label: "Call an existing patient for an appointment",
description: "Lisa's opening line when placing an AI Call to a patient — use {firstName} and {officeName} as placeholders.", description: "Lisa's opening line when placing an AI Call to a patient — use {firstName} and {officeName} as placeholders.",
value: callGreeting, value: callGreeting,
onChange: setCallGreeting, onChange: setCallGreeting,
placeholder: CALL_DEFAULTS.greeting, placeholder: CALL_DEFAULTS.greeting,
}, },
{
key: "callRecall",
icon: <Stethoscope className="h-4 w-4 text-primary" />,
label: "Recall Call: Existing Patient Check-Up Offer",
description: "Lisa's opening line for a Patient Connection \"AI Call\" — offers an existing patient a routine exam & cleaning (most insurance covers it at no cost) and asks if they'd like to book. Use {firstName} and {officeName} as placeholders.",
value: callRecallGreeting,
onChange: setCallRecallGreeting,
placeholder: CALL_DEFAULTS.recallGreeting,
},
{ {
key: "callReminder", key: "callReminder",
icon: <CalendarCheck className="h-4 w-4 text-primary" />, icon: <CalendarCheck className="h-4 w-4 text-primary" />,
@@ -1582,6 +1609,7 @@ export function AiChatSettingsCard() {
setCallReminderGreeting(CALL_DEFAULTS.reminderGreeting); setCallReminderGreeting(CALL_DEFAULTS.reminderGreeting);
setCallNewPatientGreeting(CALL_DEFAULTS.newPatientGreeting); setCallNewPatientGreeting(CALL_DEFAULTS.newPatientGreeting);
setCallGeneralFallback(CALL_DEFAULTS.generalFallback); setCallGeneralFallback(CALL_DEFAULTS.generalFallback);
setCallRecallGreeting(CALL_DEFAULTS.recallGreeting);
}} }}
> >
Reset to defaults Reset to defaults

View File

@@ -112,7 +112,7 @@ export default function PatientConnectionPage() {
to: string; to: string;
patientId: number; patientId: number;
}) => { }) => {
return apiRequest("POST", "/api/twilio/make-ai-call", { to, patientId }); return apiRequest("POST", "/api/twilio/make-ai-call", { to, patientId, purpose: "recall" });
}, },
onSuccess: () => { onSuccess: () => {
toast({ toast({