feat: persist AI conversation state in DB and fix LangGraph flow bugs
- Replace in-memory Maps in aiHandoffStore with DB-backed async functions using new patient_conversation table (stage + aiHandoff per patient) - Add afterHoursEnabled to ai_settings table (persists across restarts) - Fix runtime crash in reschedule-graph: mon/tue/wed variables were out of scope in the next-week fallback branch (ReferenceError) - Wire rescheduleGreeting and generalFallback chat templates through to LangGraph nodes so user-configured messages take effect - Add otherNode to reminder-graph to handle unclassified patient replies (e.g. "I want another appointment") and route to booking flow - Fetch chatTemplates once per webhook request instead of per stage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ const GraphState = Annotation.Root({
|
||||
reply: Annotation<string>(),
|
||||
language: Annotation<string>(),
|
||||
appointmentDatetime: Annotation<string>(),
|
||||
rescheduleGreeting: Annotation<string>(),
|
||||
generalFallback: Annotation<string>(),
|
||||
});
|
||||
|
||||
type GraphStateType = typeof GraphState.State;
|
||||
@@ -27,7 +29,7 @@ function classifyNode(state: GraphStateType) {
|
||||
function routeByIntent(state: GraphStateType): string {
|
||||
if (state.intent === "yes") return "confirm";
|
||||
if (state.intent === "no") return "reschedule";
|
||||
return END;
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ── Confirmation fallbacks (with appointment datetime) ────────────────────────
|
||||
@@ -58,6 +60,30 @@ const RESCHEDULE_FALLBACKS: Record<string, string> = {
|
||||
"Haitian Creole": "Nou konprann! Èske ou ta renmen repwograme randevou ou?",
|
||||
};
|
||||
|
||||
// ── New-appointment fallbacks (other intent, appointment keywords detected) ───
|
||||
|
||||
const NEW_APPT_FALLBACKS: Record<string, string> = {
|
||||
English: "Of course! Are you a new patient or an existing patient?",
|
||||
Spanish: "¡Por supuesto! ¿Es usted un paciente nuevo o ya ha visitado nuestra clínica?",
|
||||
Portuguese: "Claro! Você é um paciente novo ou já veio ao nosso consultório antes?",
|
||||
Mandarin: "当然!您是新患者还是我们的现有患者?",
|
||||
Cantonese: "當然!您係新病人定係我們的舊病人?",
|
||||
Arabic: "بالطبع! هل أنت مريض جديد أم مريض حالي؟",
|
||||
"Haitian Creole": "Byensèten! Èske ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
|
||||
// ── General-other fallbacks ───────────────────────────────────────────────────
|
||||
|
||||
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 confirmNode(state: GraphStateType, config: any) {
|
||||
@@ -88,7 +114,7 @@ async function confirmNode(state: GraphStateType, config: any) {
|
||||
async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const lang = state.language || "English";
|
||||
const fallback = RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!;
|
||||
const fallback = state.rescheduleGreeting || (RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!);
|
||||
|
||||
if (!apiKey) return { reply: fallback };
|
||||
|
||||
@@ -108,30 +134,76 @@ async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function otherNode(state: GraphStateType, config: any) {
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const lang = state.language || "English";
|
||||
const text = state.message.toLowerCase();
|
||||
|
||||
const isAppointmentRequest = /appointment|schedule|book|come in|visit|check.?up|cleaning|tooth|teeth|pain|dental|another|new appt/i.test(text);
|
||||
|
||||
if (isAppointmentRequest) {
|
||||
const fallback = NEW_APPT_FALLBACKS[lang] ?? NEW_APPT_FALLBACKS["English"]!;
|
||||
if (!apiKey) return { reply: fallback, intent: "wants_appointment" };
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const response = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a friendly dental office AI assistant named Lisa. The patient wants to schedule an appointment. Ask them in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`,
|
||||
},
|
||||
{ role: "user", content: `Patient said: "${state.message}"` },
|
||||
]);
|
||||
return { reply: String(response.content) || fallback, intent: "wants_appointment" };
|
||||
} catch {
|
||||
return { reply: fallback, intent: "wants_appointment" };
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = state.generalFallback || (GENERAL_FALLBACKS[lang] ?? GENERAL_FALLBACKS["English"]!);
|
||||
if (!apiKey) return { reply: fallback };
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const response = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a friendly dental office AI assistant named Lisa. 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("confirm", confirmNode)
|
||||
.addNode("reschedule", rescheduleNode)
|
||||
.addNode("other", otherNode)
|
||||
.addEdge(START, "classify")
|
||||
.addConditionalEdges("classify", routeByIntent, {
|
||||
confirm: "confirm",
|
||||
reschedule: "reschedule",
|
||||
[END]: END,
|
||||
other: "other",
|
||||
})
|
||||
.addEdge("confirm", END)
|
||||
.addEdge("reschedule", END)
|
||||
.addEdge("other", END)
|
||||
.compile();
|
||||
|
||||
export async function runReminderGraph(
|
||||
patientMessage: string,
|
||||
apiKey: string,
|
||||
language = "English",
|
||||
appointmentDatetime = ""
|
||||
appointmentDatetime = "",
|
||||
rescheduleGreeting = "",
|
||||
generalFallback = ""
|
||||
): Promise<{ reply: string | null; intent: string | null }> {
|
||||
const result = await graph.invoke(
|
||||
{ message: patientMessage, intent: "", reply: "", language, appointmentDatetime },
|
||||
{ message: patientMessage, intent: "", reply: "", language, appointmentDatetime, rescheduleGreeting, generalFallback },
|
||||
{ configurable: { apiKey } }
|
||||
);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user