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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user