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:
Gitead
2026-05-09 15:23:55 -04:00
parent e9296c68f9
commit 112529155c
321 changed files with 5096 additions and 446 deletions

View File

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

View File

@@ -5,12 +5,13 @@ import type { ConversationStage } from "./aiHandoffStore";
// ── Graph state ───────────────────────────────────────────────────────────────
const GraphState = Annotation.Root({
message: Annotation<string>(),
stage: Annotation<string>(),
intent: Annotation<string>(),
reply: Annotation<string>(),
language: Annotation<string>(),
nextStage: Annotation<string>(),
message: Annotation<string>(),
stage: Annotation<string>(),
intent: Annotation<string>(),
reply: Annotation<string>(),
language: Annotation<string>(),
nextStage: Annotation<string>(),
generalFallback: Annotation<string>(),
});
type GraphStateType = typeof GraphState.State;
@@ -377,7 +378,7 @@ async function handleSelfPayNode(state: GraphStateType, config: any) {
function transferNode(state: GraphStateType) {
const lang = state.language || "English";
return { reply: transferMsg(lang), nextStage: "done" };
return { reply: state.generalFallback || transferMsg(lang), nextStage: "done" };
}
// ── Graph assembly ────────────────────────────────────────────────────────────
@@ -419,13 +420,14 @@ const graph = new StateGraph(GraphState)
// ── Public API ────────────────────────────────────────────────────────────────
export async function runNewPatientStep(
message: string,
stage: ConversationStage,
language: string,
apiKey: string
message: string,
stage: ConversationStage,
language: string,
apiKey: string,
generalFallback = ""
): Promise<{ reply: string; nextStage: ConversationStage }> {
const result = await graph.invoke(
{ message, stage, intent: "", reply: "", language, nextStage: "" },
{ message, stage, intent: "", reply: "", language, nextStage: "", generalFallback },
{ configurable: { apiKey } }
);
return {

View File

@@ -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 {

View File

@@ -387,6 +387,7 @@ export async function runRescheduleStep(
}
// Day not clearly detected — ask again with the specific options
const { mon, tue, wed } = getNextWeekDays();
const fallbacks: Record<string, string> = {
English: `Which day works best — ${mon}, ${tue}, or ${wed}?`,
Spanish: `¿Qué día le viene mejor — el ${mon}, ${tue} o el ${wed}?`,

View File

@@ -208,7 +208,7 @@ async function runMassHealthCheckAndNotify(
// Persist and advance stage
await saveOutbound(patient.id, resultText);
setStage(patient.userId, patient.id, nextStage);
await setStage(patient.userId, patient.id, nextStage);
} catch {
// Silent — don't crash the main request
@@ -241,7 +241,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
});
// Per-patient handoff toggle must be ON
if (!getHandoff(patient.userId, patient.id)) {
if (!await getHandoff(patient.userId, patient.id)) {
res.set("Content-Type", "text/xml");
return res.send(empty());
}
@@ -252,23 +252,22 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
return res.send(empty());
}
const language = patient.preferredLanguage || "English";
const stage = getStage(patient.userId, patient.id);
const language = patient.preferredLanguage || "English";
const stage = await getStage(patient.userId, patient.id);
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
const officeContact = await storage.getOfficeContact(patient.userId);
const officeName = (officeContact as any)?.officeName?.trim() || "";
// ── Helper: send reply + set stage ─────────────────────────────────────
const reply = async (text: string, nextStage: ConversationStage) => {
await saveOutbound(patient.id, text);
setStage(patient.userId, patient.id, nextStage);
await setStage(patient.userId, patient.id, nextStage);
res.set("Content-Type", "text/xml");
return res.send(twimlReply(text));
};
// ── Stage: reminder_initial → send reminder greeting ─────────────────
if (stage === "reminder_initial") {
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
const officeContact = await storage.getOfficeContact(patient.userId);
const officeName = (officeContact as any)?.officeName?.trim() || "";
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.`;
@@ -278,10 +277,15 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
// ── Stage: greeted → classify yes/no for appointment reminder ────────
if (stage === "greeted") {
const apptDatetime = await getAppointmentDatetime(patient.id);
const { reply: aiReply, intent } = await runReminderGraph(Body, aiSettings.apiKey, language, apptDatetime);
const { reply: aiReply, intent } = await runReminderGraph(
Body, aiSettings.apiKey, language, apptDatetime,
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
);
if (aiReply) {
// YES → done; NO → start rescheduling flow
const nextStage: ConversationStage = intent === "no" ? "asked_reschedule_confirm" : "done";
let nextStage: ConversationStage;
if (intent === "no") nextStage = "asked_reschedule_confirm";
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
else nextStage = "done";
return reply(aiReply, nextStage);
}
}
@@ -332,7 +336,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
// Reply now — Selenium runs in the background
await saveOutbound(patient.id, checkingMsg);
setStage(patient.userId, patient.id, "done");
await setStage(patient.userId, patient.id, "done");
res.set("Content-Type", "text/xml");
res.send(twimlReply(checkingMsg));
@@ -380,7 +384,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
await saveOutbound(patient.id, checkingMsg);
setStage(patient.userId, patient.id, "done");
await setStage(patient.userId, patient.id, "done");
res.set("Content-Type", "text/xml");
res.send(twimlReply(checkingMsg));
@@ -403,7 +407,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
];
if (newPatientStages.includes(stage)) {
const { reply: aiReply, nextStage } = await runNewPatientStep(
Body, stage, language, aiSettings.apiKey
Body, stage, language, aiSettings.apiKey, chatTemplates.generalFallback
);
return reply(aiReply, nextStage);
}
@@ -411,14 +415,10 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
// ── Stage: initial (no active conversation) ───────────────────────────
// Check after-hours: if enabled and currently outside office hours → start new-patient flow
if (stage === "initial" || stage === "done") {
const afterHoursEnabled = getAfterHoursHandoff(patient.userId);
const afterHoursEnabled = await getAfterHoursHandoff(patient.userId);
const outsideHours = await isAfterHours(patient.userId);
if (afterHoursEnabled && outsideHours) {
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
const officeContact = await storage.getOfficeContact(patient.userId);
const officeName = (officeContact as any)?.officeName?.trim() || "";
const rawGreeting = chatTemplates.newPatientGreeting ||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?`;

View File

@@ -94,11 +94,11 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
});
// Set conversation stage based on which flow was started
if (startFlow === "new_patient") {
startNewPatientConversation(userId, pid);
await startNewPatientConversation(userId, pid);
} else if (startFlow === "reschedule") {
startRescheduleConversation(userId, pid);
await startRescheduleConversation(userId, pid);
} else {
resetConversation(userId, pid);
await resetConversation(userId, pid);
}
}
@@ -140,7 +140,7 @@ router.get("/after-hours-handoff", async (req: Request, res: Response): Promise<
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
return res.status(200).json({ enabled: getAfterHoursHandoff(userId) });
return res.status(200).json({ enabled: await getAfterHoursHandoff(userId) });
} catch (err) {
return res.status(500).json({ error: "Failed to get after-hours handoff state" });
}
@@ -153,7 +153,7 @@ router.put("/after-hours-handoff", async (req: Request, res: Response): Promise<
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { enabled } = req.body;
if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" });
setAfterHoursHandoff(userId, enabled);
await setAfterHoursHandoff(userId, enabled);
return res.status(200).json({ enabled });
} catch (err) {
return res.status(500).json({ error: "Failed to set after-hours handoff state" });
@@ -167,7 +167,7 @@ router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promis
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const patientId = parseInt(req.params.patientId);
if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" });
return res.status(200).json({ enabled: getHandoff(userId, patientId) });
return res.status(200).json({ enabled: await getHandoff(userId, patientId) });
} catch (err) {
return res.status(500).json({ error: "Failed to get AI handoff state" });
}
@@ -182,7 +182,7 @@ router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promis
if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" });
const { enabled } = req.body;
if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" });
setHandoff(userId, patientId, enabled);
await setHandoff(userId, patientId, enabled);
return res.status(200).json({ enabled });
} catch (err) {
return res.status(500).json({ error: "Failed to set AI handoff state" });