|
|
|
@@ -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);
|
|
|
|
|
}
|