feat: AI chat system with LangGraph, multi-step patient flows, and appointment rescheduling
- Add floating chat window Hand-off to AI toggle (per-patient) and after-hours AI toggle (global) - Add LangGraph-powered appointment reminder flow: AI introduces itself, classifies YES/NO, handles confirmation with appointment date/time - Add multi-step rescheduling flow: ASAP vs next week, tomorrow offer, Mon/Tue/Wed picker, morning/afternoon time slot — automatically updates appointment in DB - Add new patient / after-hours flow: new vs existing patient, dental insurance check, MassHealth Selenium eligibility check (auto-uses saved member ID + DOB for existing patients), self-pay fallback - Add AI Chat Settings page (Settings → Advanced) with editable greeting templates and LangGraph flow diagrams for both reminder and new-patient flows - Add Schedule a New Patient template option in chat window, starts new-patient conversation flow - Add GET/PUT endpoints for AI handoff, after-hours handoff, and AI chat templates - Add multilingual support (7 languages) across all AI reply nodes with LLM generation and hardcoded fallbacks - Add pending reschedule in-memory store and conversation stage tracking across all flows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
95
apps/Backend/src/ai/aiHandoffStore.ts
Normal file
95
apps/Backend/src/ai/aiHandoffStore.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// In-memory store for per-patient AI handoff toggle, conversation stage,
|
||||
// and per-user after-hours handoff toggle.
|
||||
// Conversation key: `${userId}:${patientId}`
|
||||
|
||||
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
|
||||
|
||||
const handoffStore = new Map<string, boolean>();
|
||||
const stageStore = new Map<string, ConversationStage>();
|
||||
const afterHoursStore = new Map<number, boolean>(); // keyed by userId
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
export function getPendingReschedule(userId: number, patientId: number): PendingReschedule | undefined {
|
||||
return pendingRescheduleStore.get(convKey(userId, patientId));
|
||||
}
|
||||
|
||||
export function clearPendingReschedule(userId: number, patientId: number): void {
|
||||
pendingRescheduleStore.delete(convKey(userId, patientId));
|
||||
}
|
||||
435
apps/Backend/src/ai/new-patient-graph.ts
Normal file
435
apps/Backend/src/ai/new-patient-graph.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
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>(),
|
||||
});
|
||||
|
||||
type GraphStateType = typeof GraphState.State;
|
||||
|
||||
// ── Transfer-to-staff fallback (multilingual) ─────────────────────────────────
|
||||
|
||||
const TRANSFER: Record<string, string> = {
|
||||
English: "Thank you for reaching out! Our office staff will assist you shortly.",
|
||||
Spanish: "¡Gracias por comunicarse! Un miembro del personal de la oficina le atenderá en breve.",
|
||||
Portuguese: "Obrigado pelo contato! Nossa equipe entrará em contato em breve.",
|
||||
Mandarin: "感谢您的联系!我们的工作人员将很快为您提供帮助。",
|
||||
Cantonese: "感謝您的聯繫!我們的工作人員將很快為您提供幫助。",
|
||||
Arabic: "شكراً لتواصلك! سيتواصل معك أحد موظفي المكتب قريباً.",
|
||||
"Haitian Creole": "Mèsi dèske ou kontakte nou! Yon manm ekip biwo a pral ede ou byento.",
|
||||
};
|
||||
|
||||
function transferMsg(lang: string): string {
|
||||
return TRANSFER[lang] ?? TRANSFER["English"]!;
|
||||
}
|
||||
|
||||
// ── Intent classifiers ────────────────────────────────────────────────────────
|
||||
|
||||
function wantsAppointment(text: string): boolean {
|
||||
return /appointment|schedule|cleaning|checkup|teeth|tooth|cavity|pain|dental|filling|crown|whitening|x-ray|exam|hygienist|dentist/i.test(text);
|
||||
}
|
||||
|
||||
function isNewPatient(text: string): boolean {
|
||||
return /new patient|first time|first visit|never been|brand new|haven't been|i am new/i.test(text);
|
||||
}
|
||||
|
||||
function isExistingPatient(text: string): boolean {
|
||||
return /existing|been there|have been|already|before|i have been|returning|came before|i was there/i.test(text);
|
||||
}
|
||||
|
||||
function hasMassHealth(text: string): boolean {
|
||||
return /masshealth|mass health|medicaid|masscare/i.test(text);
|
||||
}
|
||||
|
||||
function hasOtherInsurance(text: string): boolean {
|
||||
return /blue cross|delta dental|cigna|aetna|united|metlife|guardian|humana|tufts|harvard pilgrim|bmchp|yes|i have|my insurance|i do/i.test(text);
|
||||
}
|
||||
|
||||
function hasNoInsurance(text: string): boolean {
|
||||
return /no insurance|uninsured|self.pay|self pay|i don't|don't have|no i don't|i have no/i.test(text);
|
||||
}
|
||||
|
||||
function sameInsurance(text: string): boolean {
|
||||
return /yes|same|still have|haven't changed|no change/i.test(text);
|
||||
}
|
||||
|
||||
function changedInsurance(text: string): boolean {
|
||||
return /no|changed|different|new insurance|switched|lost|expired/i.test(text);
|
||||
}
|
||||
|
||||
// ── LLM reply helper ──────────────────────────────────────────────────────────
|
||||
|
||||
async function llmReply(
|
||||
system: string,
|
||||
userMsg: string,
|
||||
fallback: string,
|
||||
apiKey: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const res = await llm.invoke([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: userMsg },
|
||||
]);
|
||||
return String(res.content).trim() || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Graph nodes ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Classify intent based on current stage
|
||||
function classifyNode(state: GraphStateType) {
|
||||
const text = state.message.toLowerCase();
|
||||
const stage = state.stage as ConversationStage;
|
||||
|
||||
let intent = "other";
|
||||
|
||||
if (stage === "new_patient_greeted") {
|
||||
intent = wantsAppointment(text) ? "wants_appointment" : "other";
|
||||
} else if (stage === "asked_new_or_existing") {
|
||||
if (isNewPatient(text)) intent = "new_patient";
|
||||
else if (isExistingPatient(text)) intent = "existing_patient";
|
||||
} else if (stage === "asked_new_patient_insurance") {
|
||||
if (hasMassHealth(text)) intent = "masshealth";
|
||||
else if (hasNoInsurance(text)) intent = "no_insurance";
|
||||
else if (hasOtherInsurance(text)) intent = "other_insurance";
|
||||
} else if (stage === "asked_existing_insurance") {
|
||||
if (sameInsurance(text)) intent = "same_insurance";
|
||||
else if (changedInsurance(text)) intent = "changed_insurance";
|
||||
} else if (stage === "asked_appointment_time") {
|
||||
intent = "appointment_time";
|
||||
} else if (stage === "asked_appointment_preference") {
|
||||
intent = "appointment_preference_reply";
|
||||
} else if (stage === "asked_self_pay") {
|
||||
intent = "self_pay_reply";
|
||||
}
|
||||
|
||||
return { intent };
|
||||
}
|
||||
|
||||
function routeNode(state: GraphStateType): string {
|
||||
const stage = state.stage as ConversationStage;
|
||||
const intent = state.intent;
|
||||
|
||||
if (stage === "new_patient_greeted") return intent === "wants_appointment" ? "ask_new_or_existing" : "transfer";
|
||||
if (stage === "asked_new_or_existing") return intent === "new_patient" ? "ask_new_patient_insurance" : intent === "existing_patient" ? "ask_existing_insurance" : "transfer";
|
||||
if (stage === "asked_new_patient_insurance") return intent === "masshealth" ? "ask_masshealth_info" : intent === "no_insurance" ? "ask_appointment_time" : "transfer";
|
||||
if (stage === "asked_existing_insurance") return intent === "same_insurance" ? "ask_appointment_time" : "transfer";
|
||||
if (stage === "asked_appointment_time") return "acknowledge_appointment_time";
|
||||
if (stage === "asked_appointment_preference") return "handle_appointment_preference";
|
||||
if (stage === "asked_self_pay") return "handle_self_pay";
|
||||
|
||||
return "transfer";
|
||||
}
|
||||
|
||||
// ── Response nodes ────────────────────────────────────────────────────────────
|
||||
|
||||
async function askNewOrExistingNode(state: GraphStateType, config: any) {
|
||||
const lang = state.language || "English";
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "Are you a new patient or an existing patient?",
|
||||
Spanish: "¿Es usted un paciente nuevo o ya ha visitado nuestra clínica antes?",
|
||||
Portuguese: "Você é um paciente novo ou já veio ao nosso consultório antes?",
|
||||
Mandarin: "您是新患者还是现有患者?",
|
||||
Cantonese: "您係新病人定係舊病人?",
|
||||
Arabic: "هل أنت مريض جديد أم مريض حالي؟",
|
||||
"Haitian Creole": "Èske ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. Ask the patient in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`,
|
||||
`Patient wants an appointment. Ask if new or existing.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
|
||||
return { reply, nextStage: "asked_new_or_existing" };
|
||||
}
|
||||
|
||||
async function askNewPatientInsuranceNode(state: GraphStateType, config: any) {
|
||||
const lang = state.language || "English";
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "Do you have any dental insurance?",
|
||||
Spanish: "¿Tiene seguro dental?",
|
||||
Portuguese: "Você tem plano odontológico?",
|
||||
Mandarin: "您有牙科保险吗?",
|
||||
Cantonese: "您有牙科保險嗎?",
|
||||
Arabic: "هل لديك تأمين أسنان؟",
|
||||
"Haitian Creole": "Èske ou gen asirans dantè?",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. Ask the new patient in ${lang} if they have dental insurance. One sentence, no formatting.`,
|
||||
`New patient confirmed. Ask about insurance.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
|
||||
return { reply, nextStage: "asked_new_patient_insurance" };
|
||||
}
|
||||
|
||||
async function askExistingInsuranceNode(state: GraphStateType, config: any) {
|
||||
const lang = state.language || "English";
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "Do you still have the same insurance?",
|
||||
Spanish: "¿Sigue teniendo el mismo seguro?",
|
||||
Portuguese: "Você ainda tem o mesmo plano?",
|
||||
Mandarin: "您还有相同的保险吗?",
|
||||
Cantonese: "您仍然有相同的保險嗎?",
|
||||
Arabic: "هل لا تزال تمتلك نفس التأمين؟",
|
||||
"Haitian Creole": "Èske ou toujou gen menm asirans lan?",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. Ask the existing patient in ${lang} if they still have the same dental insurance on file. One sentence, no formatting.`,
|
||||
`Existing patient confirmed. Ask about insurance.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
|
||||
return { reply, nextStage: "asked_existing_insurance" };
|
||||
}
|
||||
|
||||
async function askMassHealthInfoNode(state: GraphStateType, config: any) {
|
||||
const lang = state.language || "English";
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "I can check your MassHealth coverage! Please text me your Member ID and date of birth.",
|
||||
Spanish: "¡Puedo verificar su cobertura de MassHealth! Por favor envíeme su número de miembro y fecha de nacimiento.",
|
||||
Portuguese: "Posso verificar sua cobertura MassHealth! Por favor envie seu número de membro e data de nascimento.",
|
||||
Mandarin: "我可以查看您的MassHealth保险!请发送您的会员ID和出生日期。",
|
||||
Cantonese: "我可以查核您的MassHealth保險!請傳送您的會員ID和出生日期。",
|
||||
Arabic: "يمكنني التحقق من تغطية MassHealth الخاصة بك! من فضلك أرسل لي رقم العضوية وتاريخ الميلاد.",
|
||||
"Haitian Creole": "Mwen ka verifye asirans MassHealth ou! Tanpri voye ID manm ou ak dat nesans ou.",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. The patient has MassHealth. Tell them in ${lang} that you can check their coverage and ask them to send their Member ID and date of birth. 1-2 sentences, no formatting.`,
|
||||
`Patient has MassHealth. Ask for member ID and DOB.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
|
||||
return { reply, nextStage: "awaiting_masshealth_info" };
|
||||
}
|
||||
|
||||
async function askAppointmentTimeNode(state: GraphStateType, config: any) {
|
||||
const lang = state.language || "English";
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "When would you like to make an appointment?",
|
||||
Spanish: "¿Cuándo le gustaría hacer una cita?",
|
||||
Portuguese: "Quando você gostaria de agendar uma consulta?",
|
||||
Mandarin: "您想什么时候预约?",
|
||||
Cantonese: "您想幾時預約?",
|
||||
Arabic: "متى تريد تحديد موعد؟",
|
||||
"Haitian Creole": "Ki lè ou ta renmen fè yon randevou?",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. Ask the patient in ${lang} when they would like to schedule their appointment. One sentence, no formatting.`,
|
||||
`Ask when to schedule.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
|
||||
return { reply, nextStage: "asked_appointment_time" };
|
||||
}
|
||||
|
||||
async function acknowledgeAppointmentTimeNode(state: GraphStateType, config: any) {
|
||||
const lang = state.language || "English";
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "Thank you! Our office staff will confirm your appointment details shortly.",
|
||||
Spanish: "¡Gracias! El personal de la oficina confirmará los detalles de su cita en breve.",
|
||||
Portuguese: "Obrigado! Nossa equipe confirmará os detalhes da sua consulta em breve.",
|
||||
Mandarin: "谢谢!我们的工作人员将很快确认您的预约详情。",
|
||||
Cantonese: "多謝!我們的工作人員將很快確認您的預約詳情。",
|
||||
Arabic: "شكراً! سيؤكد فريق مكتبنا تفاصيل موعدك قريباً.",
|
||||
"Haitian Creole": "Mèsi! Ekip biwo nou an pral konfime detay randevou ou a byento.",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. The patient stated their preferred appointment time. Acknowledge in ${lang} and tell them the office staff will confirm shortly. 1-2 sentences, no formatting.`,
|
||||
`Patient said: "${state.message}". Acknowledge.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
|
||||
// ── Post-Selenium: appointment preference (MassHealth ACTIVE) ─────────────────
|
||||
|
||||
async function handleAppointmentPreferenceNode(state: GraphStateType, config: any) {
|
||||
const lang = state.language || "English";
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "When would you like to come in? Are you looking for a routine check-up and teeth cleaning, or do you have a tooth problem or pain?",
|
||||
Spanish: "¿Cuándo le gustaría venir? ¿Busca una revisión rutinaria y limpieza dental, o tiene algún problema dental o dolor?",
|
||||
Portuguese: "Quando gostaria de vir? Você busca uma consulta de rotina e limpeza, ou tem algum problema dentário ou dor?",
|
||||
Mandarin: "您想什么时候来?您是想做常规检查和洗牙,还是您有牙齿问题或疼痛?",
|
||||
Cantonese: "您想幾時來?您是想做例行檢查和洗牙,還是您有牙齒問題或疼痛?",
|
||||
Arabic: "متى تودّ الحضور؟ هل تبحث عن فحص روتيني وتنظيف أسنان، أم أن لديك مشكلة في الأسنان أو ألماً؟",
|
||||
"Haitian Creole": "Ki lè ou ta renmen vini? Èske ou ap chèche yon egzamen woutin ak netwayaj dan, oswa èske ou gen yon pwoblèm dan oswa doulè?",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. The patient's MassHealth is active. In ${lang}, ask when they would like to come in and whether they want a routine check-up and teeth cleaning, or if they have a dental problem or pain. 1-2 sentences, no formatting.`,
|
||||
`MassHealth is active. Ask appointment preference.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
|
||||
return { reply, nextStage: "asked_appointment_time" };
|
||||
}
|
||||
|
||||
// ── Post-Selenium: self-pay offer (MassHealth INACTIVE) ───────────────────────
|
||||
|
||||
async function handleSelfPayNode(state: GraphStateType, config: any) {
|
||||
const lang = state.language || "English";
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const text = state.message.toLowerCase();
|
||||
|
||||
// Classify yes/no for self-pay
|
||||
const acceptsSelfPay = /yes|sure|ok|okay|yep|yeah|sí|si|claro|sim|confirmado|好的|نعم|wi|oke/i.test(text);
|
||||
const declinesSelfPay = /no|nope|can't|won't|not interested|no puedo|não|لا|pa ka/i.test(text);
|
||||
|
||||
if (declinesSelfPay) {
|
||||
const declineFallbacks: Record<string, string> = {
|
||||
English: "No problem! If you ever change your mind or have any questions, feel free to reach out. Our staff is happy to help.",
|
||||
Spanish: "¡Sin problema! Si cambia de opinión o tiene alguna pregunta, no dude en contactarnos.",
|
||||
Portuguese: "Sem problema! Se mudar de ideia ou tiver dúvidas, entre em contato. Nossa equipe está feliz em ajudar.",
|
||||
Mandarin: "没关系!如果您改变主意或有任何问题,随时联系我们,我们的工作人员很乐意帮助您。",
|
||||
Cantonese: "沒問題!如果您改變主意或有任何問題,隨時聯繫我們。",
|
||||
Arabic: "لا بأس! إذا غيّرت رأيك أو كان لديك أي سؤال، لا تتردد في التواصل معنا.",
|
||||
"Haitian Creole": "Pa gen pwoblèm! Si ou chanje lide ou oswa gen kesyon, pa ezite kontakte nou.",
|
||||
};
|
||||
const fallback = declineFallbacks[lang] ?? declineFallbacks["English"]!;
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. The patient declined a self-pay appointment. In ${lang}, politely close the conversation and invite them to reach out anytime. 1-2 sentences, no formatting.`,
|
||||
`Patient declined self-pay.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
|
||||
if (acceptsSelfPay) {
|
||||
const acceptFallbacks: Record<string, string> = {
|
||||
English: "When would you like to come in for your examination?",
|
||||
Spanish: "¿Cuándo le gustaría venir para su examen?",
|
||||
Portuguese: "Quando você gostaria de vir para seu exame?",
|
||||
Mandarin: "您想什么时候来进行检查?",
|
||||
Cantonese: "您想幾時來進行檢查?",
|
||||
Arabic: "متى تودّ الحضور للفحص؟",
|
||||
"Haitian Creole": "Ki lè ou ta renmen vini pou egzamen ou?",
|
||||
};
|
||||
const fallback = acceptFallbacks[lang] ?? acceptFallbacks["English"]!;
|
||||
const reply = apiKey
|
||||
? await llmReply(
|
||||
`You are a friendly dental office AI assistant. The patient agreed to a self-pay examination. In ${lang}, ask when they would like to come in. One sentence, no formatting.`,
|
||||
`Patient agreed to self-pay. Ask when to come in.`,
|
||||
fallback, apiKey
|
||||
)
|
||||
: fallback;
|
||||
return { reply, nextStage: "asked_appointment_time" };
|
||||
}
|
||||
|
||||
// Ambiguous — transfer to staff
|
||||
return { reply: transferMsg(lang), nextStage: "done" };
|
||||
}
|
||||
|
||||
function transferNode(state: GraphStateType) {
|
||||
const lang = state.language || "English";
|
||||
return { reply: transferMsg(lang), nextStage: "done" };
|
||||
}
|
||||
|
||||
// ── Graph assembly ────────────────────────────────────────────────────────────
|
||||
|
||||
const graph = new StateGraph(GraphState)
|
||||
.addNode("classify", classifyNode)
|
||||
.addNode("ask_new_or_existing", askNewOrExistingNode)
|
||||
.addNode("ask_new_patient_insurance", askNewPatientInsuranceNode)
|
||||
.addNode("ask_existing_insurance", askExistingInsuranceNode)
|
||||
.addNode("ask_masshealth_info", askMassHealthInfoNode)
|
||||
.addNode("ask_appointment_time", askAppointmentTimeNode)
|
||||
.addNode("acknowledge_appointment_time", acknowledgeAppointmentTimeNode)
|
||||
.addNode("handle_appointment_preference",handleAppointmentPreferenceNode)
|
||||
.addNode("handle_self_pay", handleSelfPayNode)
|
||||
.addNode("transfer", transferNode)
|
||||
.addEdge(START, "classify")
|
||||
.addConditionalEdges("classify", routeNode, {
|
||||
ask_new_or_existing: "ask_new_or_existing",
|
||||
ask_new_patient_insurance: "ask_new_patient_insurance",
|
||||
ask_existing_insurance: "ask_existing_insurance",
|
||||
ask_masshealth_info: "ask_masshealth_info",
|
||||
ask_appointment_time: "ask_appointment_time",
|
||||
acknowledge_appointment_time: "acknowledge_appointment_time",
|
||||
handle_appointment_preference: "handle_appointment_preference",
|
||||
handle_self_pay: "handle_self_pay",
|
||||
transfer: "transfer",
|
||||
})
|
||||
.addEdge("ask_new_or_existing", END)
|
||||
.addEdge("ask_new_patient_insurance", END)
|
||||
.addEdge("ask_existing_insurance", END)
|
||||
.addEdge("ask_masshealth_info", END)
|
||||
.addEdge("ask_appointment_time", END)
|
||||
.addEdge("acknowledge_appointment_time", END)
|
||||
.addEdge("handle_appointment_preference", END)
|
||||
.addEdge("handle_self_pay", END)
|
||||
.addEdge("transfer", END)
|
||||
.compile();
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runNewPatientStep(
|
||||
message: string,
|
||||
stage: ConversationStage,
|
||||
language: string,
|
||||
apiKey: string
|
||||
): Promise<{ reply: string; nextStage: ConversationStage }> {
|
||||
const result = await graph.invoke(
|
||||
{ message, stage, intent: "", reply: "", language, nextStage: "" },
|
||||
{ configurable: { apiKey } }
|
||||
);
|
||||
return {
|
||||
reply: result.reply || transferMsg(language),
|
||||
nextStage: (result.nextStage as ConversationStage) || "done",
|
||||
};
|
||||
}
|
||||
@@ -2,44 +2,80 @@ import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
|
||||
const GraphState = Annotation.Root({
|
||||
message: Annotation<string>(),
|
||||
intent: Annotation<string>(),
|
||||
reply: Annotation<string>(),
|
||||
message: Annotation<string>(),
|
||||
intent: Annotation<string>(),
|
||||
reply: Annotation<string>(),
|
||||
language: Annotation<string>(),
|
||||
appointmentDatetime: Annotation<string>(),
|
||||
});
|
||||
|
||||
type GraphStateType = typeof GraphState.State;
|
||||
|
||||
// Keyword-based intent classifier — fast and deterministic for yes/no
|
||||
// ── Intent classifier — multilingual yes/no keywords ─────────────────────────
|
||||
|
||||
function classifyNode(state: GraphStateType) {
|
||||
const text = state.message.toLowerCase().trim();
|
||||
|
||||
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely)\b/;
|
||||
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going)\b/;
|
||||
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|confirmed|confirm|will be there|sounds good|see you|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|confirmo|de acuerdo|seguro|estaré|sim|confirmado|com certeza|好的|确认|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/;
|
||||
const noPatterns = /\b(no|nope|can't|cannot|won't|not available|unavailable|cancel|reschedule|busy|sorry|unable|not coming|not going|no puedo|no podré|cancelar|reprogramar|ocupado|lo siento|não posso|não vou|reagendar|占线|无法|取消|لا|لا أستطيع|إلغاء|pa kapab|pa ka)\b/;
|
||||
|
||||
if (yesPatterns.test(text)) return { intent: "yes" };
|
||||
if (noPatterns.test(text)) return { intent: "no" };
|
||||
if (noPatterns.test(text)) return { intent: "no" };
|
||||
return { intent: "other" };
|
||||
}
|
||||
|
||||
function routeByIntent(state: GraphStateType): string {
|
||||
if (state.intent === "yes") return "thank_you";
|
||||
if (state.intent === "no") return "reschedule";
|
||||
if (state.intent === "yes") return "confirm";
|
||||
if (state.intent === "no") return "reschedule";
|
||||
return END;
|
||||
}
|
||||
|
||||
async function thankYouNode(state: GraphStateType, config: any) {
|
||||
// ── Confirmation fallbacks (with appointment datetime) ────────────────────────
|
||||
|
||||
function buildConfirmFallback(lang: string, apptDatetime: string): string {
|
||||
const appt = apptDatetime || "your scheduled appointment";
|
||||
const map: Record<string, string> = {
|
||||
English: `Thank you for your confirmation! We look forward to seeing you on ${appt}.`,
|
||||
Spanish: `¡Gracias por confirmar! Le esperamos el ${appt}.`,
|
||||
Portuguese: `Obrigado por confirmar! Aguardamos a sua visita em ${appt}.`,
|
||||
Mandarin: `感谢您的确认!我们期待在 ${appt} 见到您。`,
|
||||
Cantonese: `感謝您的確認!我們期待在 ${appt} 見到您。`,
|
||||
Arabic: `شكراً على تأكيدك! نتطلع إلى رؤيتك في ${appt}.`,
|
||||
"Haitian Creole": `Mèsi dèske ou konfime! N'ap tann ou ${appt}.`,
|
||||
};
|
||||
return map[lang] ?? map["English"]!;
|
||||
}
|
||||
|
||||
// ── Reschedule fallbacks ──────────────────────────────────────────────────────
|
||||
|
||||
const RESCHEDULE_FALLBACKS: Record<string, string> = {
|
||||
English: "It is understandable! Would you like to reschedule?",
|
||||
Spanish: "¡Lo entendemos! ¿Le gustaría reprogramar su cita?",
|
||||
Portuguese: "Entendemos! Gostaria de reagendar a sua consulta?",
|
||||
Mandarin: "我们理解!您想重新安排预约吗?",
|
||||
Cantonese: "我們理解!您想重新安排預約嗎?",
|
||||
Arabic: "نتفهم ذلك! هل تود إعادة جدولة موعدك؟",
|
||||
"Haitian Creole": "Nou konprann! Èske ou ta renmen repwograme randevou ou?",
|
||||
};
|
||||
|
||||
// ── LangGraph nodes ───────────────────────────────────────────────────────────
|
||||
|
||||
async function confirmNode(state: GraphStateType, config: any) {
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const fallback = "Thank you for confirming your appointment! We look forward to seeing you.";
|
||||
const lang = state.language || "English";
|
||||
const appt = state.appointmentDatetime || "";
|
||||
const fallback = buildConfirmFallback(lang, appt);
|
||||
|
||||
if (!apiKey) return { reply: fallback };
|
||||
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const apptClause = appt ? ` Their appointment is on ${appt}.` : "";
|
||||
const response = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking a patient who just confirmed their appointment. Do not add any formatting or extra text.",
|
||||
`You are a friendly dental office assistant. Write a short, warm SMS reply (1-2 sentences max) thanking the patient for confirming their appointment and reminding them of the date and time.${apptClause} You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`,
|
||||
},
|
||||
{ role: "user", content: `Patient replied: "${state.message}"` },
|
||||
]);
|
||||
@@ -51,7 +87,8 @@ async function thankYouNode(state: GraphStateType, config: any) {
|
||||
|
||||
async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const fallback = "We understand! Our assistant will contact you shortly to help reschedule.";
|
||||
const lang = state.language || "English";
|
||||
const fallback = RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!;
|
||||
|
||||
if (!apiKey) return { reply: fallback };
|
||||
|
||||
@@ -61,7 +98,7 @@ async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a friendly dental office assistant. Write a short, empathetic SMS reply (1-2 sentences max) to a patient who can't make their appointment. Tell them an assistant will contact them soon to reschedule. Do not add any formatting or extra text.",
|
||||
`You are a friendly dental office assistant. The patient cannot make their appointment. Write a short, empathetic SMS reply (1 sentence max) that says it is understandable and asks if they would like to reschedule. You MUST reply in ${lang} regardless of the language the patient used. Do not add any formatting or extra text.`,
|
||||
},
|
||||
{ role: "user", content: `Patient replied: "${state.message}"` },
|
||||
]);
|
||||
@@ -71,30 +108,34 @@ async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Graph ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const graph = new StateGraph(GraphState)
|
||||
.addNode("classify", classifyNode)
|
||||
.addNode("thank_you", thankYouNode)
|
||||
.addNode("classify", classifyNode)
|
||||
.addNode("confirm", confirmNode)
|
||||
.addNode("reschedule", rescheduleNode)
|
||||
.addEdge(START, "classify")
|
||||
.addConditionalEdges("classify", routeByIntent, {
|
||||
thank_you: "thank_you",
|
||||
confirm: "confirm",
|
||||
reschedule: "reschedule",
|
||||
[END]: END,
|
||||
[END]: END,
|
||||
})
|
||||
.addEdge("thank_you", END)
|
||||
.addEdge("confirm", END)
|
||||
.addEdge("reschedule", END)
|
||||
.compile();
|
||||
|
||||
export async function runReminderGraph(
|
||||
patientMessage: string,
|
||||
apiKey: string
|
||||
patientMessage: string,
|
||||
apiKey: string,
|
||||
language = "English",
|
||||
appointmentDatetime = ""
|
||||
): Promise<{ reply: string | null; intent: string | null }> {
|
||||
const result = await graph.invoke(
|
||||
{ message: patientMessage, intent: "", reply: "" },
|
||||
{ message: patientMessage, intent: "", reply: "", language, appointmentDatetime },
|
||||
{ configurable: { apiKey } }
|
||||
);
|
||||
return {
|
||||
reply: result.reply || null,
|
||||
reply: result.reply || null,
|
||||
intent: result.intent || null,
|
||||
};
|
||||
}
|
||||
|
||||
470
apps/Backend/src/ai/reschedule-graph.ts
Normal file
470
apps/Backend/src/ai/reschedule-graph.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { storage } from "../storage";
|
||||
import {
|
||||
type ConversationStage,
|
||||
setPendingReschedule,
|
||||
getPendingReschedule,
|
||||
clearPendingReschedule,
|
||||
} from "./aiHandoffStore";
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MONTHS = [
|
||||
"January","February","March","April","May","June",
|
||||
"July","August","September","October","November","December",
|
||||
];
|
||||
const DAYS = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
|
||||
|
||||
function formattedDate(d: Date): string {
|
||||
return `${DAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
||||
}
|
||||
|
||||
/** Get the day-of-week (0=Sun…6=Sat) of the patient's next scheduled appointment. */
|
||||
async function getAppointmentDow(patientId: number): Promise<number> {
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const appt = await db.appointment.findFirst({
|
||||
where: { patientId, status: "scheduled", date: { gte: today } },
|
||||
orderBy: { date: "asc" },
|
||||
select: { date: true },
|
||||
});
|
||||
if (!appt) return -1;
|
||||
return new Date(appt.date).getUTCDay();
|
||||
}
|
||||
|
||||
function getTomorrow(): string {
|
||||
const t = new Date();
|
||||
t.setDate(t.getDate() + 1);
|
||||
return formattedDate(t);
|
||||
}
|
||||
|
||||
function getNextWeekDays(): { mon: string; tue: string; wed: string } {
|
||||
const today = new Date();
|
||||
const dow = today.getDay();
|
||||
// Days until next Monday (always at least 1 day away, never 0)
|
||||
const daysToMon = (8 - dow) % 7 || 7;
|
||||
const mon = new Date(today); mon.setDate(today.getDate() + daysToMon);
|
||||
const tue = new Date(mon); tue.setDate(mon.getDate() + 1);
|
||||
const wed = new Date(mon); wed.setDate(mon.getDate() + 2);
|
||||
return { mon: formattedDate(mon), tue: formattedDate(tue), wed: formattedDate(wed) };
|
||||
}
|
||||
|
||||
// ── Date objects for rescheduling ─────────────────────────────────────────────
|
||||
|
||||
function getTomorrowDate(): { date: Date; label: string } {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return { date: d, label: formattedDate(new Date(d.getTime() + d.getTimezoneOffset() * 60000)) };
|
||||
}
|
||||
|
||||
function getNextWeekDateObjects(): { mon: { date: Date; label: string }; tue: { date: Date; label: string }; wed: { date: Date; label: string } } {
|
||||
const today = new Date();
|
||||
const dow = today.getDay();
|
||||
const daysToMon = (8 - dow) % 7 || 7;
|
||||
|
||||
const mkDate = (offset: number) => {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() + offset);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
const label = formattedDate(new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset));
|
||||
return { date: d, label };
|
||||
};
|
||||
return { mon: mkDate(daysToMon), tue: mkDate(daysToMon + 1), wed: mkDate(daysToMon + 2) };
|
||||
}
|
||||
|
||||
// ── Time parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse patient's time preference into 24-h "HH:MM" string or null. */
|
||||
async function parseTime(message: string, apiKey: string): Promise<string | null> {
|
||||
const t = message.toLowerCase();
|
||||
|
||||
// Keyword shortcuts
|
||||
if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00";
|
||||
if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00";
|
||||
|
||||
// Numeric patterns: "10am", "10:30", "2pm", "14:00"
|
||||
const ampm = t.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/);
|
||||
const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/);
|
||||
|
||||
if (ampm) {
|
||||
let h = parseInt(ampm[1]!);
|
||||
const m = ampm[2] ? parseInt(ampm[2]) : 0;
|
||||
if (ampm[3] === "pm" && h < 12) h += 12;
|
||||
if (ampm[3] === "am" && h === 12) h = 0;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
if (clock) return clock[0]!;
|
||||
|
||||
// LLM fallback
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const res = await llm.invoke([
|
||||
{ role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format (e.g., "10:00", "14:30"). If no time is mentioned, return "null".' },
|
||||
{ role: "user", content: message },
|
||||
]);
|
||||
const raw = String(res.content).trim();
|
||||
if (/^([01]?\d|2[0-3]):[0-5]\d$/.test(raw)) return raw;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Appointment update ────────────────────────────────────────────────────────
|
||||
|
||||
async function moveAppointment(
|
||||
patientId: number,
|
||||
newDate: Date,
|
||||
newStartTime: string, // "HH:MM"
|
||||
): Promise<string> {
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const appt = await db.appointment.findFirst({
|
||||
where: { patientId, status: "scheduled", date: { gte: today } },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
if (!appt) return "no_appointment";
|
||||
|
||||
// Preserve original duration
|
||||
const [sh, sm] = appt.startTime.split(":").map(Number);
|
||||
const [eh, em] = appt.endTime.split(":").map(Number);
|
||||
const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!);
|
||||
const [nh, nm] = newStartTime.split(":").map(Number);
|
||||
const endTotalMin = nh! * 60 + nm! + durationMin;
|
||||
const newEndTime = `${String(Math.floor(endTotalMin / 60)).padStart(2, "0")}:${String(endTotalMin % 60).padStart(2, "0")}`;
|
||||
|
||||
await storage.updateAppointment(appt.id, {
|
||||
date: newDate,
|
||||
startTime: newStartTime,
|
||||
endTime: newEndTime,
|
||||
status: "scheduled",
|
||||
} as any);
|
||||
|
||||
return "ok";
|
||||
}
|
||||
|
||||
// ── Intent classifiers ────────────────────────────────────────────────────────
|
||||
|
||||
function yes(t: string) {
|
||||
return /\b(yes|yeah|yep|sure|ok|okay|please|absolutely|definitely|sí|si|sim|好的|نعم|wi|oke)\b/i.test(t);
|
||||
}
|
||||
function no(t: string) {
|
||||
return /\b(no|nope|not|don't|won't|can't|لا|pa ka|não)\b/i.test(t);
|
||||
}
|
||||
function wantsAsap(t: string) {
|
||||
return /\b(asap|soon|soonest|next day|tomorrow|quick|fastest|earliest|lo antes|mañana|amanhã|明天|明日|غدًا|demen)\b/i.test(t);
|
||||
}
|
||||
function wantsNextWeek(t: string) {
|
||||
return /\b(next week|semana|prochain|prochaine|下周|下週|الأسبوع القادم|semèn pwochèn)\b/i.test(t);
|
||||
}
|
||||
function prefersMonday(t: string) { return /monday|lunes|segunda|周一|月曜|الاثنين|lendi/i.test(t); }
|
||||
function prefersTuesday(t: string) { return /tuesday|martes|terça|周二|火曜|الثلاثاء|madi/i.test(t); }
|
||||
function prefersWednesday(t: string) { return /wednesday|miércoles|quarta|周三|水曜|الأربعاء|mèkredi/i.test(t); }
|
||||
|
||||
// ── LLM helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function llmReply(system: string, user: string, fallback: string, apiKey: string): Promise<string> {
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const res = await llm.invoke([
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user },
|
||||
]);
|
||||
return String(res.content).trim() || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transfer fallback ─────────────────────────────────────────────────────────
|
||||
|
||||
const TRANSFER: Record<string, string> = {
|
||||
English: "Our office staff will contact you shortly to help with rescheduling.",
|
||||
Spanish: "El personal de nuestra oficina le contactará pronto para ayudarle a reprogramar.",
|
||||
Portuguese: "Nossa equipe entrará em contato em breve para ajudá-lo a reagendar.",
|
||||
Mandarin: "我们的工作人员将很快与您联系以帮助重新安排预约。",
|
||||
Cantonese: "我們的工作人員將很快聯絡您以協助重新安排預約。",
|
||||
Arabic: "سيتصل بك موظفو مكتبنا قريباً لمساعدتك في إعادة الجدولة.",
|
||||
"Haitian Creole": "Anplwaye biwo nou yo pral kontakte ou byento pou ede ou repwograme.",
|
||||
};
|
||||
|
||||
// ── Main step function ────────────────────────────────────────────────────────
|
||||
|
||||
export async function runRescheduleStep(
|
||||
message: string,
|
||||
stage: ConversationStage,
|
||||
language: string,
|
||||
patientId: number,
|
||||
apiKey: string,
|
||||
userId: number = 0,
|
||||
): Promise<{ reply: string; nextStage: ConversationStage }> {
|
||||
|
||||
const lang = language || "English";
|
||||
const t = message.toLowerCase();
|
||||
const tx = TRANSFER[lang] ?? TRANSFER["English"]!;
|
||||
|
||||
// ── asked_reschedule_confirm: patient answered "Would you like to reschedule?" ──
|
||||
if (stage === "asked_reschedule_confirm") {
|
||||
if (no(t)) {
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "No problem! Feel free to reach out whenever you're ready. Have a great day!",
|
||||
Spanish: "¡Sin problema! No dude en contactarnos cuando esté listo. ¡Que tenga un buen día!",
|
||||
Portuguese: "Sem problema! Entre em contato quando estiver pronto. Tenha um ótimo dia!",
|
||||
Mandarin: "没关系!随时联系我们。祝您有美好的一天!",
|
||||
Cantonese: "沒問題!隨時聯繫我們。祝您有美好的一天!",
|
||||
Arabic: "لا بأس! لا تتردد في التواصل معنا متى كنت مستعداً. أتمنى لك يوماً رائعاً!",
|
||||
"Haitian Creole": "Pa gen pwoblèm! Kontakte nou nenpòt ki lè ou prèt. Pase yon bèl jounen!",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient does not want to reschedule. Write a warm, brief closing message in ${lang}. 1 sentence, no formatting.`,
|
||||
`Patient said: "${message}"`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
|
||||
if (yes(t)) {
|
||||
// Check if original appointment was Mon–Thu (days 1–4)
|
||||
const dow = await getAppointmentDow(patientId);
|
||||
const isMonToThu = dow >= 1 && dow <= 4;
|
||||
|
||||
if (isMonToThu) {
|
||||
// Offer ASAP or next week
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "Would you like to reschedule as soon as possible, or would you prefer next week?",
|
||||
Spanish: "¿Le gustaría reprogramar lo antes posible, o prefiere la semana que viene?",
|
||||
Portuguese: "Gostaria de reagendar o mais rápido possível, ou prefere a semana que vem?",
|
||||
Mandarin: "您想尽快重新安排预约,还是下周更方便?",
|
||||
Cantonese: "您想盡快重新安排預約,還是下週更方便?",
|
||||
Arabic: "هل تفضل إعادة الجدولة في أقرب وقت ممكن، أم تفضل الأسبوع القادم؟",
|
||||
"Haitian Creole": "Èske ou ta renmen repwograme pi vit posib, oswa ou prefere semèn pwochèn?",
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient wants to reschedule. Their original appointment was on a weekday. Ask in ${lang} whether they prefer to reschedule as soon as possible or next week. 1 sentence, no formatting.`,
|
||||
`Patient said: "${message}"`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_preference" };
|
||||
} else {
|
||||
// Original appointment was Fri/Sat/Sun — go straight to next week
|
||||
const { mon, tue, wed } = getNextWeekDays();
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`,
|
||||
Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`,
|
||||
Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom para você?`,
|
||||
Mandarin: `我可以安排您在下周预约。${mon}、${tue} 或 ${wed} 方便吗?`,
|
||||
Cantonese: `我可以安排您在下週預約。${mon}、${tue} 或 ${wed} 方便嗎?`,
|
||||
Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`,
|
||||
"Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay pou ou?`,
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. Offer the patient next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
|
||||
`Patient wants to reschedule.`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||||
}
|
||||
}
|
||||
|
||||
return { reply: tx, nextStage: "done" };
|
||||
}
|
||||
|
||||
// ── asked_reschedule_preference: patient chose ASAP or next week ──────────
|
||||
if (stage === "asked_reschedule_preference") {
|
||||
if (wantsAsap(t) || yes(t)) {
|
||||
const tomorrow = getTomorrow();
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `Can you come in tomorrow, ${tomorrow}?`,
|
||||
Spanish: `¿Podría venir mañana, ${tomorrow}?`,
|
||||
Portuguese: `Você pode vir amanhã, ${tomorrow}?`,
|
||||
Mandarin: `您明天(${tomorrow})能来吗?`,
|
||||
Cantonese: `您明天(${tomorrow})能來嗎?`,
|
||||
Arabic: `هل يمكنك الحضور غداً، ${tomorrow}؟`,
|
||||
"Haitian Creole": `Èske ou ka vini demen, ${tomorrow}?`,
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. Ask the patient in ${lang} if they can come in tomorrow, ${tomorrow}. 1 sentence, no formatting.`,
|
||||
`Patient wants to reschedule ASAP.`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_asap" };
|
||||
}
|
||||
|
||||
if (wantsNextWeek(t)) {
|
||||
const { mon, tue, wed } = getNextWeekDays();
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`,
|
||||
Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`,
|
||||
Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom para você?`,
|
||||
Mandarin: `我可以安排您在下周预约。${mon}、${tue} 或 ${wed} 方便吗?`,
|
||||
Cantonese: `我可以安排您在下週預約。${mon}、${tue} 或 ${wed} 方便嗎?`,
|
||||
Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`,
|
||||
"Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay pou ou?`,
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
|
||||
`Patient prefers next week.`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||||
}
|
||||
|
||||
return { reply: tx, nextStage: "done" };
|
||||
}
|
||||
|
||||
// ── asked_reschedule_asap: patient answered "Can you come tomorrow?" ───────
|
||||
if (stage === "asked_reschedule_asap") {
|
||||
if (yes(t)) {
|
||||
const { date, label } = getTomorrowDate();
|
||||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: label });
|
||||
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `${label} it is! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`,
|
||||
Spanish: `¡${label} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`,
|
||||
Portuguese: `${label} ótimo! Você prefere manhã (9h–12h) ou tarde (13h–17h)?`,
|
||||
Mandarin: `${label},太好了!您想预约上午(9点–12点)还是下午(1点–5点)?`,
|
||||
Cantonese: `${label},太好了!您想預約上午(9點–12點)還是下午(1點–5點)?`,
|
||||
Arabic: `${label} ممتاز! هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟`,
|
||||
"Haitian Creole": `${label} pafè! Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?`,
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient confirmed ${label}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
|
||||
`Patient confirmed tomorrow.`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_time" };
|
||||
}
|
||||
|
||||
if (no(t)) {
|
||||
// Can't make tomorrow — offer next week instead
|
||||
const { mon, tue, wed } = getNextWeekDays();
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work for you?`,
|
||||
Spanish: `¡Sin problema! ¿Qué le parece la semana que viene? ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`,
|
||||
Portuguese: `Sem problema! E na semana que vem? ${mon}, ${tue} ou ${wed} seria bom?`,
|
||||
Mandarin: `没关系!下周怎么样?${mon}、${tue} 或 ${wed} 方便吗?`,
|
||||
Cantonese: `沒問題!下週怎麼樣?${mon}、${tue} 或 ${wed} 方便嗎?`,
|
||||
Arabic: `لا بأس! ماذا عن الأسبوع القادم؟ هل ${mon} أو ${tue} أو ${wed} يناسبك؟`,
|
||||
"Haitian Creole": `Pa gen pwoblèm! Ki sa ki dire semèn pwochèn? ${mon}, ${tue}, oswa ${wed} ka travay?`,
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`,
|
||||
`Patient can't come tomorrow.`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||||
}
|
||||
|
||||
return { reply: tx, nextStage: "done" };
|
||||
}
|
||||
|
||||
// ── asked_reschedule_next_week: patient choosing Mon/Tue/Wed ─────────────
|
||||
if (stage === "asked_reschedule_next_week") {
|
||||
const nwd = getNextWeekDateObjects();
|
||||
let chosen: { date: Date; label: string } | null = null;
|
||||
if (prefersMonday(t)) chosen = nwd.mon;
|
||||
if (prefersTuesday(t)) chosen = nwd.tue;
|
||||
if (prefersWednesday(t)) chosen = nwd.wed;
|
||||
|
||||
if (chosen) {
|
||||
setPendingReschedule(userId, patientId, { newDate: chosen.date, dayLabel: chosen.label });
|
||||
|
||||
const day = chosen.label;
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `${day} works! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`,
|
||||
Spanish: `¡${day} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`,
|
||||
Portuguese: `${day} ótimo! Você prefere manhã (9h–12h) ou tarde (13h–17h)?`,
|
||||
Mandarin: `${day},好的!您想预约上午(9点–12点)还是下午(1点–5点)?`,
|
||||
Cantonese: `${day},好的!您想預約上午(9點–12點)還是下午(1點–5點)?`,
|
||||
Arabic: `${day} رائع! هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟`,
|
||||
"Haitian Creole": `${day} pafè! Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?`,
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient chose ${day}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
|
||||
`Patient chose ${day}.`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "asked_reschedule_time" };
|
||||
}
|
||||
|
||||
// Day not clearly detected — ask again with the specific options
|
||||
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}?`,
|
||||
Portuguese: `Qual dia é melhor — ${mon}, ${tue} ou ${wed}?`,
|
||||
Mandarin: `哪天最方便——${mon}、${tue} 还是 ${wed}?`,
|
||||
Cantonese: `哪天最方便——${mon}、${tue} 還是 ${wed}?`,
|
||||
Arabic: `أي يوم هو الأفضل لك — ${mon} أو ${tue} أو ${wed}؟`,
|
||||
"Haitian Creole": `Ki jou ki pi bon — ${mon}, ${tue}, oswa ${wed}?`,
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
return { reply: fallback, nextStage: "asked_reschedule_next_week" };
|
||||
}
|
||||
|
||||
// ── asked_reschedule_time: patient picked morning / afternoon / specific time ──
|
||||
if (stage === "asked_reschedule_time") {
|
||||
const pending = getPendingReschedule(userId, patientId);
|
||||
|
||||
if (!pending) {
|
||||
// Edge case: lost state — fall back gracefully
|
||||
return { reply: tx, nextStage: "done" };
|
||||
}
|
||||
|
||||
const startTime = await parseTime(message, apiKey);
|
||||
|
||||
if (!startTime) {
|
||||
// Couldn't parse time — ask again
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "I didn't catch the time. Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?",
|
||||
Spanish: "No entendí la hora. ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?",
|
||||
Portuguese: "Não entendi o horário. Você prefere manhã (9h–12h) ou tarde (13h–17h)?",
|
||||
Mandarin: "我没听清时间。您想预约上午(9点–12点)还是下午(1点–5点)?",
|
||||
Cantonese: "我沒聽清時間。您想預約上午(9點–12點)還是下午(1點–5點)?",
|
||||
Arabic: "لم أفهم الوقت. هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟",
|
||||
"Haitian Creole": "Mwen pa konprann lè a. Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?",
|
||||
};
|
||||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time" };
|
||||
}
|
||||
|
||||
// Update the appointment in the database
|
||||
const updateResult = await moveAppointment(patientId, pending.newDate, startTime);
|
||||
clearPendingReschedule(userId, patientId);
|
||||
|
||||
const [h, m] = startTime.split(":").map(Number);
|
||||
const h12 = h! % 12 || 12;
|
||||
const ampm = h! >= 12 ? "pm" : "am";
|
||||
const timeLabel = `${h12}:${String(m!).padStart(2, "0")} ${ampm}`;
|
||||
const apptLabel = `${pending.dayLabel} at ${timeLabel}`;
|
||||
|
||||
if (updateResult === "no_appointment") {
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `I couldn't find your appointment to update. Our staff will contact you to confirm ${apptLabel}.`,
|
||||
Spanish: `No encontré su cita para actualizar. El personal le contactará para confirmar el ${apptLabel}.`,
|
||||
Portuguese: `Não encontrei sua consulta para atualizar. Nossa equipe entrará em contato para confirmar ${apptLabel}.`,
|
||||
Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${apptLabel}。`,
|
||||
Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${apptLabel}。`,
|
||||
Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${apptLabel}.`,
|
||||
"Haitian Creole": `Mwen pa jwenn randevou ou pou mete ajou. Anplwaye nou yo pral kontakte ou pou konfime ${apptLabel}.`,
|
||||
};
|
||||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" };
|
||||
}
|
||||
|
||||
// Success
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `Your appointment has been moved to ${apptLabel}. See you then!`,
|
||||
Spanish: `Su cita ha sido cambiada al ${apptLabel}. ¡Hasta entonces!`,
|
||||
Portuguese: `Sua consulta foi remarcada para ${apptLabel}. Até lá!`,
|
||||
Mandarin: `您的预约已更改为${apptLabel}。到时见!`,
|
||||
Cantonese: `您的預約已更改為${apptLabel}。到時見!`,
|
||||
Arabic: `تم تغيير موعدك إلى ${apptLabel}. نراك قريباً!`,
|
||||
"Haitian Creole": `Randevou ou a deplase ale nan ${apptLabel}. N'ap wè ou lè sa a!`,
|
||||
};
|
||||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||||
const reply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient's appointment has been successfully rescheduled to ${apptLabel}. Confirm in ${lang} with enthusiasm. 1 sentence, no formatting.`,
|
||||
`Appointment moved to ${apptLabel}.`, fallback, apiKey
|
||||
);
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
|
||||
return { reply: tx, nextStage: "done" };
|
||||
}
|
||||
@@ -36,4 +36,30 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/ai/chat-templates
|
||||
router.get("/chat-templates", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const templates = await storage.getAiChatTemplates(userId);
|
||||
return res.status(200).json(templates);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to fetch AI chat templates", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/ai/chat-templates
|
||||
router.put("/chat-templates", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const { reminderGreeting, newPatientGreeting, generalFallback } = req.body;
|
||||
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback });
|
||||
const updated = await storage.getAiChatTemplates(userId);
|
||||
return res.status(200).json(updated);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to save AI chat templates", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,64 +1,442 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import twilio from "twilio";
|
||||
import { storage } from "../storage";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { runReminderGraph } from "../ai/reminder-graph";
|
||||
import { runNewPatientStep } from "../ai/new-patient-graph";
|
||||
import { runRescheduleStep } from "../ai/reschedule-graph";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
||||
import {
|
||||
getHandoff, getAfterHoursHandoff,
|
||||
getStage, setStage,
|
||||
type ConversationStage,
|
||||
} from "../ai/aiHandoffStore";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/twilio/webhook/sms (Twilio posts inbound SMS here — no auth)
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function twimlReply(text: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(text)}</Message></Response>`;
|
||||
}
|
||||
|
||||
function empty(): string {
|
||||
return "<Response></Response>";
|
||||
}
|
||||
|
||||
/** Get the patient's next scheduled appointment as a human-readable string. */
|
||||
async function getAppointmentDatetime(patientId: number): Promise<string> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const appt = await db.appointment.findFirst({
|
||||
where: { patientId, status: "scheduled", date: { gte: today } },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
if (!appt) return "";
|
||||
const months = ["January","February","March","April","May","June",
|
||||
"July","August","September","October","November","December"];
|
||||
const d = new Date(appt.date);
|
||||
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} at ${appt.startTime}`;
|
||||
}
|
||||
|
||||
/** Check if right now is outside office hours for the given user. */
|
||||
async function isAfterHours(userId: number): Promise<boolean> {
|
||||
const record = await storage.getOfficeHours(userId);
|
||||
if (!record?.data) return false; // no hours configured → treat as in-hours
|
||||
|
||||
const data = record.data as any;
|
||||
const now = new Date();
|
||||
const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
|
||||
const day = days[now.getDay()];
|
||||
|
||||
const slot = data.doctors?.[day];
|
||||
if (!slot?.enabled) return true; // office closed today
|
||||
|
||||
const hhmm = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`;
|
||||
if (hhmm >= slot.amStart && hhmm <= slot.amEnd) return false;
|
||||
if (hhmm >= slot.pmStart && hhmm <= slot.pmEnd) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Substitute {officeName} in a template string. */
|
||||
function applyOfficeName(template: string, name: string): string {
|
||||
return template.replace(/\{officeName\}/g, name || "our dental office");
|
||||
}
|
||||
|
||||
/** Save an outbound message and return the text. */
|
||||
async function saveOutbound(patientId: number, body: string): Promise<void> {
|
||||
await storage.createCommunication({
|
||||
patientId, channel: "sms", direction: "outbound", status: "sent", body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MassHealth Member ID and date of birth from a free-text SMS.
|
||||
* Tries regex first, falls back to LLM extraction.
|
||||
*/
|
||||
async function parseMassHealthInfo(
|
||||
message: string,
|
||||
apiKey: string
|
||||
): Promise<{ memberId: string | null; dob: string | null }> {
|
||||
// Regex: member IDs are typically 8-12 digits; DOB as MM/DD/YYYY or similar
|
||||
const idMatch = message.match(/\b(\d{8,12})\b/);
|
||||
const dobMatch = message.match(/\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{2,4})\b/);
|
||||
|
||||
if (idMatch && dobMatch) {
|
||||
const [, m, d, y] = dobMatch;
|
||||
const year = y!.length === 2 ? `20${y}` : y;
|
||||
return { memberId: idMatch[1]!, dob: `${m}/${d}/${year}` };
|
||||
}
|
||||
|
||||
// Fall back to LLM structured extraction
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const res = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'Extract the insurance member ID and date of birth from the patient message. ' +
|
||||
'Return ONLY valid JSON: {"memberId":"...","dob":"MM/DD/YYYY"}. Use null for missing fields.',
|
||||
},
|
||||
{ role: "user", content: message },
|
||||
]);
|
||||
const raw = String(res.content).replace(/```json|```/g, "").trim();
|
||||
const json = JSON.parse(raw);
|
||||
return { memberId: json.memberId ?? null, dob: json.dob ?? null };
|
||||
} catch {
|
||||
return { memberId: null, dob: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run MassHealth eligibility check in the background (after replying to patient)
|
||||
* and send the result as a follow-up SMS.
|
||||
*/
|
||||
async function runMassHealthCheckAndNotify(
|
||||
patient: { id: number; userId: number; phone: string | null; preferredLanguage: string | null },
|
||||
memberId: string,
|
||||
dob: string,
|
||||
apiKey: string,
|
||||
isExistingPatient = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(patient.userId, "MH");
|
||||
if (!credentials) return;
|
||||
|
||||
const twilioSettings = await storage.getTwilioSettings(patient.userId);
|
||||
if (!twilioSettings || !patient.phone) return;
|
||||
|
||||
// Run Selenium eligibility check directly via the processor
|
||||
await runEligibilityProcessor({
|
||||
userId: patient.userId,
|
||||
insuranceId: memberId,
|
||||
formDob: dob,
|
||||
enrichedPayload: {
|
||||
memberId,
|
||||
dateOfBirth: dob,
|
||||
insuranceSiteKey: "MH",
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-fetch updated patient status
|
||||
const updated = await db.patient.findUnique({
|
||||
where: { id: patient.id },
|
||||
select: { status: true, firstName: true },
|
||||
});
|
||||
|
||||
const lang = patient.preferredLanguage || "English";
|
||||
const active = updated?.status === "ACTIVE";
|
||||
|
||||
// ── ACTIVE: existing patient → simple scheduling; new patient → preference ─
|
||||
const activeMessagesExisting: Record<string, string> = {
|
||||
English: "Great news! Your MassHealth coverage is active. When would you like to come in for your appointment?",
|
||||
Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¿Cuándo le gustaría venir para su cita?",
|
||||
Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Quando gostaria de vir para sua consulta?",
|
||||
Mandarin: "好消息!您的MassHealth保险有效。您想什么时候来预约?",
|
||||
Cantonese: "好消息!您的MassHealth保險有效。您想幾時來預約?",
|
||||
Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. متى تودّ الحضور لموعدك؟",
|
||||
"Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Ki lè ou ta renmen vini pou randevou ou?",
|
||||
};
|
||||
|
||||
const activeMessagesNew: Record<string, string> = {
|
||||
English: "Great news! Your MassHealth coverage is active. When would you like to come in? Are you looking for a routine check-up and teeth cleaning, or do you have a tooth problem or pain?",
|
||||
Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¿Cuándo le gustaría venir? ¿Busca una revisión rutinaria y limpieza dental, o tiene algún problema dental o dolor?",
|
||||
Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Quando gostaria de vir? Você busca uma consulta de rotina e limpeza, ou tem algum problema dentário ou dor?",
|
||||
Mandarin: "好消息!您的MassHealth保险有效。您想什么时候来?您是想做常规检查和洗牙,还是您有牙齿问题或疼痛?",
|
||||
Cantonese: "好消息!您的MassHealth保險有效。您想幾時來?您是想做例行檢查和洗牙,還是您有牙齒問題或疼痛?",
|
||||
Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. متى تودّ الحضور؟ هل تبحث عن فحص روتيني وتنظيف أسنان، أم أن لديك مشكلة في الأسنان أو ألماً؟",
|
||||
"Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Ki lè ou ta renmen vini? Èske ou ap chèche yon egzamen woutin ak netwayaj dan, oswa ou gen pwoblèm dan oswa doulè?",
|
||||
};
|
||||
|
||||
const activeMessages = isExistingPatient ? activeMessagesExisting : activeMessagesNew;
|
||||
|
||||
// ── INACTIVE: offer self-pay examination ────────────────────────────
|
||||
const inactiveMessages: Record<string, string> = {
|
||||
English: "We checked your MassHealth coverage. Unfortunately the plan appears inactive or could not be verified. Would you still like to schedule an examination appointment as a self-pay patient?",
|
||||
Spanish: "Verificamos su cobertura de MassHealth. Lamentablemente el plan aparece inactivo o no pudo ser verificado. ¿Le gustaría programar una cita de examen como paciente de pago particular?",
|
||||
Portuguese: "Verificamos sua cobertura MassHealth. Infelizmente o plano parece inativo ou não pôde ser verificado. Gostaria de agendar uma consulta de exame como paciente particular?",
|
||||
Mandarin: "我们查看了您的MassHealth保险。遗憾的是,保险似乎无效或无法验证。您仍然希望以自费方式预约检查吗?",
|
||||
Cantonese: "我們查看了您的MassHealth保險。遺憾地,保險似乎無效或無法核實。您仍然希望以自費方式預約檢查嗎?",
|
||||
Arabic: "تحققنا من تغطيتك من MassHealth. للأسف يبدو أن الخطة غير نشطة أو لا يمكن التحقق منها. هل تودّ تحديد موعد فحص كمريض يدفع من حسابه الخاص؟",
|
||||
"Haitian Creole": "Nou te verifye kouvèti MassHealth ou. Malerezman plan an sanble inaktif oswa pa ka verifye. Èske ou ta renmen pran yon randevou egzamen kòm pasyan ki peye poukont li?",
|
||||
};
|
||||
|
||||
const resultText = active
|
||||
? (activeMessages[lang] ?? activeMessages["English"]!)
|
||||
: (inactiveMessages[lang] ?? inactiveMessages["English"]!);
|
||||
|
||||
const nextStage: ConversationStage = active
|
||||
? (isExistingPatient ? "asked_appointment_time" : "asked_appointment_preference")
|
||||
: "asked_self_pay";
|
||||
|
||||
// Send follow-up question via Twilio
|
||||
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
|
||||
await client.messages.create({
|
||||
body: resultText,
|
||||
from: twilioSettings.phoneNumber,
|
||||
to: patient.phone,
|
||||
});
|
||||
|
||||
// Persist and advance stage
|
||||
await saveOutbound(patient.id, resultText);
|
||||
setStage(patient.userId, patient.id, nextStage);
|
||||
|
||||
} catch {
|
||||
// Silent — don't crash the main request
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/twilio/webhook/sms ──────────────────────────────────────────────
|
||||
|
||||
router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { From, Body, MessageSid } = req.body;
|
||||
|
||||
const normalizedFrom = (From || "").replace(/\D/g, "");
|
||||
const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true } });
|
||||
const allPatients = await db.patient.findMany({
|
||||
select: { id: true, phone: true, userId: true, preferredLanguage: true },
|
||||
});
|
||||
const patient = allPatients.find(
|
||||
(p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom
|
||||
(p) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom
|
||||
);
|
||||
|
||||
if (patient) {
|
||||
// Save the inbound message
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id,
|
||||
channel: "sms",
|
||||
direction: "inbound",
|
||||
status: "delivered",
|
||||
body: Body,
|
||||
twilioSid: MessageSid,
|
||||
});
|
||||
if (!patient) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
// Run AI graph if API key is configured
|
||||
const aiSettings = await storage.getAiSettings(patient.userId);
|
||||
if (aiSettings?.apiKey) {
|
||||
const { reply, intent } = await runReminderGraph(Body, aiSettings.apiKey);
|
||||
// Save inbound message
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id, channel: "sms", direction: "inbound",
|
||||
status: "delivered", body: Body, twilioSid: MessageSid,
|
||||
});
|
||||
|
||||
if (reply) {
|
||||
// Save the AI outbound reply
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id,
|
||||
channel: "sms",
|
||||
direction: "outbound",
|
||||
status: "sent",
|
||||
body: reply,
|
||||
});
|
||||
// Per-patient handoff toggle must be ON
|
||||
if (!getHandoff(patient.userId, patient.id)) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
const aiSettings = await storage.getAiSettings(patient.userId);
|
||||
if (!aiSettings?.apiKey) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
const language = patient.preferredLanguage || "English";
|
||||
const stage = getStage(patient.userId, patient.id);
|
||||
|
||||
// ── Helper: send reply + set stage ─────────────────────────────────────
|
||||
const reply = async (text: string, nextStage: ConversationStage) => {
|
||||
await saveOutbound(patient.id, text);
|
||||
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.`;
|
||||
|
||||
return reply(applyOfficeName(rawGreeting, officeName), "greeted");
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
if (aiReply) {
|
||||
// YES → done; NO → start rescheduling flow
|
||||
const nextStage: ConversationStage = intent === "no" ? "asked_reschedule_confirm" : "done";
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rescheduling flow stages ───────────────────────────────────────────
|
||||
const rescheduleStages: ConversationStage[] = [
|
||||
"asked_reschedule_confirm", "asked_reschedule_preference",
|
||||
"asked_reschedule_asap", "asked_reschedule_next_week",
|
||||
"asked_reschedule_time",
|
||||
];
|
||||
if (rescheduleStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runRescheduleStep(
|
||||
Body, stage, language, patient.id, aiSettings.apiKey, patient.userId
|
||||
);
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
|
||||
// ── Stage: awaiting MassHealth member ID + DOB ────────────────────────
|
||||
if (stage === "awaiting_masshealth_info") {
|
||||
const { memberId, dob } = await parseMassHealthInfo(Body, aiSettings.apiKey);
|
||||
|
||||
if (!memberId || !dob) {
|
||||
// Couldn't parse — ask again with a clearer format hint
|
||||
const retryMessages: Record<string, string> = {
|
||||
English: "I couldn't read your Member ID and date of birth. Please reply in this format: Member ID: 12345678 DOB: 01/01/1990",
|
||||
Spanish: "No pude leer su número de miembro y fecha de nacimiento. Por favor responda así: ID: 12345678 Fecha: 01/01/1990",
|
||||
Portuguese: "Não consegui ler seu número de membro e data de nascimento. Por favor responda assim: ID: 12345678 Data: 01/01/1990",
|
||||
Mandarin: "我无法读取您的会员ID和出生日期。请按以下格式回复:ID: 12345678 生日: 01/01/1990",
|
||||
Cantonese: "我無法讀取您的會員ID和出生日期。請按以下格式回覆:ID: 12345678 生日: 01/01/1990",
|
||||
Arabic: "لم أتمكن من قراءة رقم العضوية وتاريخ الميلاد. يرجى الرد بالصيغة التالية: ID: 12345678 DOB: 01/01/1990",
|
||||
"Haitian Creole": "Mwen pa t ka li ID manm ou ak dat nesans. Tanpri reponn konsa: ID: 12345678 DOB: 01/01/1990",
|
||||
};
|
||||
const retryMsg = retryMessages[language] ?? retryMessages["English"]!;
|
||||
return reply(retryMsg, "awaiting_masshealth_info");
|
||||
}
|
||||
|
||||
// Immediately confirm to the patient and start the check in background
|
||||
const checkingMessages: Record<string, string> = {
|
||||
English: "Thank you! I'm checking your MassHealth eligibility now. I'll send you the result in a moment.",
|
||||
Spanish: "¡Gracias! Estoy verificando su elegibilidad de MassHealth ahora. Le enviaré el resultado en un momento.",
|
||||
Portuguese: "Obrigado! Estou verificando sua elegibilidade MassHealth agora. Enviarei o resultado em instantes.",
|
||||
Mandarin: "谢谢!我正在查询您的MassHealth资格。稍后我会发送结果给您。",
|
||||
Cantonese: "多謝!我正在查詢您的MassHealth資格。稍後我會發送結果給您。",
|
||||
Arabic: "شكراً! أقوم بالتحقق من أهليتك في MassHealth الآن. سأرسل لك النتيجة قريباً.",
|
||||
"Haitian Creole": "Mèsi! Mwen ap verifye kalifikasyon MassHealth ou kounye a. M ap voye rezilta a nan yon ti moman.",
|
||||
};
|
||||
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
|
||||
|
||||
// Reply now — Selenium runs in the background
|
||||
await saveOutbound(patient.id, checkingMsg);
|
||||
setStage(patient.userId, patient.id, "done");
|
||||
res.set("Content-Type", "text/xml");
|
||||
res.send(twimlReply(checkingMsg));
|
||||
|
||||
// Fire-and-forget: run check and send result SMS when complete
|
||||
runMassHealthCheckAndNotify(patient, memberId, dob, aiSettings.apiKey).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Stage: existing patient said YES to same insurance ───────────────
|
||||
// Special case: if they have MassHealth on file, run Selenium check
|
||||
// automatically (we already have their member ID + DOB in DB).
|
||||
if (stage === "asked_existing_insurance") {
|
||||
const saysYes = /yes|same|still have|haven't changed|no change|yep|yeah|sí|si|sim|好的|نعم|wi/i.test(Body);
|
||||
|
||||
if (saysYes) {
|
||||
const patientRecord = await db.patient.findUnique({
|
||||
where: { id: patient.id },
|
||||
select: { insuranceProvider: true, insuranceId: true, dateOfBirth: true },
|
||||
});
|
||||
|
||||
const isMassHealth = /masshealth|mass health|masscare|medicaid/i.test(
|
||||
patientRecord?.insuranceProvider ?? ""
|
||||
);
|
||||
|
||||
if (isMassHealth && patientRecord?.insuranceId) {
|
||||
// Format DOB as MM/DD/YYYY for Selenium
|
||||
let dobStr = "";
|
||||
if (patientRecord.dateOfBirth) {
|
||||
const d = new Date(patientRecord.dateOfBirth);
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const yy = d.getUTCFullYear();
|
||||
dobStr = `${mm}/${dd}/${yy}`;
|
||||
}
|
||||
|
||||
const checkingMessages: Record<string, string> = {
|
||||
English: "Please wait about 30-60 seconds! I'm double-checking your MassHealth coverage right now.",
|
||||
Spanish: "¡Por favor espere unos 30-60 segundos! Estoy verificando su cobertura de MassHealth ahora mismo.",
|
||||
Portuguese: "Por favor aguarde cerca de 30-60 segundos! Estou verificando sua cobertura MassHealth agora.",
|
||||
Mandarin: "请等待约30-60秒!我现在正在为您核查MassHealth保险。",
|
||||
Cantonese: "請等待約30-60秒!我現在正在為您核查MassHealth保險。",
|
||||
Arabic: "يرجى الانتظار حوالي 30-60 ثانية! أقوم الآن بالتحقق من تغطيتك في MassHealth.",
|
||||
"Haitian Creole": "Tanpri tann anviwon 30-60 segonn! Mwen ap verifye kouvèti MassHealth ou kounye a.",
|
||||
};
|
||||
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
|
||||
|
||||
await saveOutbound(patient.id, checkingMsg);
|
||||
setStage(patient.userId, patient.id, "done");
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(reply)}</Message></Response>`
|
||||
);
|
||||
res.send(twimlReply(checkingMsg));
|
||||
|
||||
// Fire-and-forget Selenium check; existing patient gets simpler result
|
||||
runMassHealthCheckAndNotify(
|
||||
patient, patientRecord.insuranceId, dobStr, aiSettings.apiKey, true
|
||||
).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Not MassHealth or said NO — fall through to normal graph handling
|
||||
}
|
||||
|
||||
// ── Stage: new_patient_greeted + multi-step new patient stages ────────
|
||||
const newPatientStages: ConversationStage[] = [
|
||||
"new_patient_greeted", "asked_new_or_existing",
|
||||
"asked_new_patient_insurance", "asked_existing_insurance",
|
||||
"asked_appointment_time",
|
||||
"asked_appointment_preference", "asked_self_pay",
|
||||
];
|
||||
if (newPatientStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
||||
Body, stage, language, aiSettings.apiKey
|
||||
);
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
|
||||
// ── 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 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?`;
|
||||
|
||||
return reply(applyOfficeName(rawGreeting, officeName), "new_patient_greeted");
|
||||
}
|
||||
}
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
return res.send(empty());
|
||||
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
return res.send(empty());
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/webhook/voice (Twilio posts here when someone calls — no auth)
|
||||
// ── POST /api/twilio/webhook/voice ────────────────────────────────────────────
|
||||
|
||||
router.post("/webhook/voice", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { From, CallSid } = req.body;
|
||||
@@ -72,69 +450,51 @@ router.post("/webhook/voice", async (req: Request, res: Response): Promise<any>
|
||||
let greeting = "Thank you for calling. Please leave a message after the beep and we will get back to you shortly.";
|
||||
if (patient) {
|
||||
const settings = await storage.getTwilioSettings(patient.userId);
|
||||
if (settings?.greetingMessage?.trim()) {
|
||||
greeting = settings.greetingMessage.trim();
|
||||
}
|
||||
if (settings?.greetingMessage?.trim()) greeting = settings.greetingMessage.trim();
|
||||
}
|
||||
|
||||
if (patient) {
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id,
|
||||
channel: "voice",
|
||||
direction: "inbound",
|
||||
status: "completed",
|
||||
body: "(Inbound call — voicemail below)",
|
||||
twilioSid: CallSid,
|
||||
patientId: patient.id, channel: "voice", direction: "inbound",
|
||||
status: "completed", body: "(Inbound call — voicemail below)", twilioSid: CallSid,
|
||||
});
|
||||
}
|
||||
|
||||
const recordingCallbackUrl = `${process.env.BASE_URL || "https://communitydentistsoflowell.mydentalofficemanagement.com"}/api/twilio/webhook/voice-recording`;
|
||||
|
||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">${greeting}</Say>
|
||||
<Record maxLength="120" action="${recordingCallbackUrl}" transcribeCallback="${recordingCallbackUrl}" playBeep="true"/>
|
||||
<Say voice="alice">We did not receive a recording. Goodbye.</Say>
|
||||
</Response>`;
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twiml);
|
||||
</Response>`);
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>Thank you for calling. Please try again later.</Say></Response>`);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/webhook/voice-recording (Twilio posts recording URL here — no auth)
|
||||
// ── POST /api/twilio/webhook/voice-recording ──────────────────────────────────
|
||||
|
||||
router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { CallSid, RecordingUrl } = req.body;
|
||||
|
||||
if (RecordingUrl && CallSid) {
|
||||
const comm = await db.communication.findFirst({ where: { twilioSid: CallSid } });
|
||||
if (comm) {
|
||||
await db.communication.update({
|
||||
where: { id: comm.id },
|
||||
data: { body: `Voicemail: ${RecordingUrl}.mp3` },
|
||||
data: { body: `Voicemail: ${RecordingUrl}.mp3` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
return res.send(empty());
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
return res.send(empty());
|
||||
}
|
||||
});
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import twilio from "twilio";
|
||||
import { storage } from "../storage";
|
||||
import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -65,7 +66,7 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { to, message, patientId } = req.body;
|
||||
const { to, message, patientId, startFlow } = req.body;
|
||||
if (!to || !message) return res.status(400).json({ message: "to and message are required" });
|
||||
|
||||
const settings = await storage.getTwilioSettings(userId);
|
||||
@@ -81,8 +82,9 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
});
|
||||
|
||||
if (patientId) {
|
||||
const pid = Number(patientId);
|
||||
await storage.createCommunication({
|
||||
patientId: Number(patientId),
|
||||
patientId: pid,
|
||||
userId,
|
||||
channel: "sms",
|
||||
direction: "outbound",
|
||||
@@ -90,6 +92,12 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
body: message,
|
||||
twilioSid: twilioMsg.sid,
|
||||
});
|
||||
// Set conversation stage based on which flow was started
|
||||
if (startFlow === "new_patient") {
|
||||
startNewPatientConversation(userId, pid);
|
||||
} else {
|
||||
resetConversation(userId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ sid: twilioMsg.sid, status: twilioMsg.status });
|
||||
@@ -125,6 +133,60 @@ router.put("/templates/:key", async (req: Request, res: Response): Promise<any>
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/twilio/after-hours-handoff
|
||||
router.get("/after-hours-handoff", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
return res.status(200).json({ enabled: getAfterHoursHandoff(userId) });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to get after-hours handoff state" });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/twilio/after-hours-handoff
|
||||
router.put("/after-hours-handoff", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
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);
|
||||
return res.status(200).json({ enabled });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to set after-hours handoff state" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/twilio/ai-handoff/:patientId
|
||||
router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
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) });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to get AI handoff state" });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/twilio/ai-handoff/:patientId
|
||||
router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
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" });
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" });
|
||||
setHandoff(userId, patientId, enabled);
|
||||
return res.status(200).json({ enabled });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to set AI handoff state" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/twilio/recent-communications
|
||||
router.get("/recent-communications", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
@@ -59,6 +59,30 @@ export const twilioStorage = {
|
||||
});
|
||||
},
|
||||
|
||||
async getAiChatTemplates(userId: number): Promise<Record<string, string>> {
|
||||
const settings = await db.twilioSettings.findUnique({ where: { userId } });
|
||||
const all = (settings?.templates as Record<string, string>) || {};
|
||||
return {
|
||||
reminderGreeting: all["_ai_chat_reminder_greeting"] ?? "",
|
||||
newPatientGreeting: all["_ai_chat_new_patient_greeting"] ?? "",
|
||||
generalFallback: all["_ai_chat_general_fallback"] ?? "",
|
||||
};
|
||||
},
|
||||
|
||||
async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string }) {
|
||||
const settings = await db.twilioSettings.findUnique({ where: { userId } });
|
||||
const existing = (settings?.templates as Record<string, string>) || {};
|
||||
const updated: Record<string, string> = { ...existing };
|
||||
if (templates.reminderGreeting !== undefined) updated["_ai_chat_reminder_greeting"] = templates.reminderGreeting;
|
||||
if (templates.newPatientGreeting !== undefined) updated["_ai_chat_new_patient_greeting"] = templates.newPatientGreeting;
|
||||
if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback;
|
||||
return db.twilioSettings.upsert({
|
||||
where: { userId },
|
||||
update: { templates: updated },
|
||||
create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated },
|
||||
});
|
||||
},
|
||||
|
||||
async getRecentCommunicationsByUser(userId: number, limit = 20) {
|
||||
return db.communication.findMany({
|
||||
where: { patient: { userId } },
|
||||
|
||||
@@ -245,6 +245,11 @@ export function Sidebar() {
|
||||
path: "/settings/ai",
|
||||
icon: <Bot className="h-4 w-4 text-gray-400" />,
|
||||
},
|
||||
{
|
||||
name: "AI Chat Settings",
|
||||
path: "/settings/aichat",
|
||||
icon: <Bot className="h-4 w-4 text-gray-400" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Send, ArrowLeft, FileText, Globe } from "lucide-react";
|
||||
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { Patient, Communication } from "@repo/db/types";
|
||||
import { format, isToday, isYesterday, parseISO } from "date-fns";
|
||||
import { es, pt, zhCN, zhTW, ar, fr } from "date-fns/locale";
|
||||
@@ -264,6 +265,37 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
|
||||
: "English"
|
||||
);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [handOffToAI, setHandOffToAI] = useState(true);
|
||||
const [pendingStartFlow, setPendingStartFlow] = useState<"new_patient" | null>(null);
|
||||
|
||||
useQuery<{ enabled: boolean }>({
|
||||
queryKey: ["/api/twilio/ai-handoff", patient.id],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", `/api/twilio/ai-handoff/${patient.id}`);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data: { enabled: boolean }) => setHandOffToAI(data.enabled),
|
||||
} as any);
|
||||
|
||||
const { data: aiChatTemplates } = useQuery<{ newPatientGreeting: string } | null>({
|
||||
queryKey: ["/api/ai/chat-templates"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/ai/chat-templates");
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const handoffMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) =>
|
||||
apiRequest("PUT", `/api/twilio/ai-handoff/${patient.id}`, { enabled }),
|
||||
});
|
||||
|
||||
const handleHandoffToggle = (enabled: boolean) => {
|
||||
setHandOffToAI(enabled);
|
||||
handoffMutation.mutate(enabled);
|
||||
};
|
||||
|
||||
const { data: officeContact } = useQuery<OfficeContact | null>({
|
||||
queryKey: ["/api/office-contact"],
|
||||
@@ -290,9 +322,11 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
|
||||
to: patient.phone,
|
||||
message,
|
||||
patientId: patient.id,
|
||||
...(pendingStartFlow ? { startFlow: pendingStartFlow } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setMessageText("");
|
||||
setPendingStartFlow(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/patients", patient.id, "communications"] });
|
||||
toast({ title: "Message sent", description: "Your message has been sent successfully." });
|
||||
},
|
||||
@@ -395,14 +429,29 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(key) => {
|
||||
const tpl = templates.find((t) => t.key === key);
|
||||
if (tpl) setMessageText(tpl.body);
|
||||
if (key === "__new_patient__") {
|
||||
const greeting = aiChatTemplates?.newPatientGreeting ||
|
||||
"Hi! My name is Lisa, the dedicated AI assistant at our dental office. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?";
|
||||
setMessageText(greeting);
|
||||
setPendingStartFlow("new_patient");
|
||||
} else {
|
||||
const tpl = templates.find((t) => t.key === key);
|
||||
if (tpl) { setMessageText(tpl.body); setPendingStartFlow(null); }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs border-dashed w-[180px]">
|
||||
<SelectValue placeholder="Use a template…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* New patient scheduling — uses AI New Patient Greeting */}
|
||||
<SelectItem value="__new_patient__" className="text-xs font-medium text-primary">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<UserPlus className="h-3 w-3" />
|
||||
Schedule a New Patient
|
||||
</span>
|
||||
</SelectItem>
|
||||
<div className="my-1 border-t" />
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.key} value={t.key} className="text-xs">
|
||||
{t.label}
|
||||
@@ -411,6 +460,25 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* New-patient flow indicator */}
|
||||
{pendingStartFlow === "new_patient" && (
|
||||
<div className="flex items-center gap-1 text-xs text-primary bg-primary/5 border border-primary/20 rounded px-2 py-0.5">
|
||||
<UserPlus className="h-3 w-3" />
|
||||
New patient flow
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI handoff toggle */}
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<Bot className={`h-3.5 w-3.5 flex-shrink-0 ${handOffToAI ? "text-primary" : "text-muted-foreground"}`} />
|
||||
<span className="text-xs text-muted-foreground">Hand off to AI</span>
|
||||
<Switch
|
||||
checked={handOffToAI}
|
||||
onCheckedChange={handleHandoffToggle}
|
||||
className="scale-75 origin-left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
751
apps/Frontend/src/components/settings/ai-chat-settings-card.tsx
Normal file
751
apps/Frontend/src/components/settings/ai-chat-settings-card.tsx
Normal file
@@ -0,0 +1,751 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AiChatTemplates = {
|
||||
reminderGreeting: string;
|
||||
newPatientGreeting: string;
|
||||
generalFallback: string;
|
||||
};
|
||||
|
||||
type OfficeContact = {
|
||||
officeName?: string | null;
|
||||
};
|
||||
|
||||
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULTS = {
|
||||
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.",
|
||||
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?",
|
||||
generalFallback: "How can I help you today?",
|
||||
};
|
||||
|
||||
function previewTemplate(text: string, officeName: string) {
|
||||
return text.replace(/\{officeName\}/g, officeName || "your dental office");
|
||||
}
|
||||
|
||||
// ─── LangGraph flow diagram (SVG) ─────────────────────────────────────────────
|
||||
|
||||
function LangGraphFlow() {
|
||||
const W = 620;
|
||||
const cx = W / 2; // 310
|
||||
const nodeW = 200;
|
||||
const nx = cx - nodeW / 2; // 210
|
||||
|
||||
// sequential node y positions & heights
|
||||
const n1y = 16, n1h = 58;
|
||||
const n2y = 116, n2h = 58;
|
||||
const n3y = 216, n3h = 78;
|
||||
|
||||
// fork geometry
|
||||
const forkHY = n3y + n3h + 22; // 316
|
||||
const lcx = 150, rcx = 470; // branch centers (balanced around 310)
|
||||
|
||||
// main branch nodes
|
||||
const branchY = forkHY + 58; // 374
|
||||
const branchW = 220;
|
||||
const branchH = 88;
|
||||
const branchBottom = branchY + branchH; // 462
|
||||
|
||||
// ── Rescheduling sub-tree (below NO/left branch at lcx=150) ──────────────
|
||||
const rsForkHY = branchBottom + 28; // 490 patient says YES/NO to reschedule
|
||||
const rsYesCx = 90;
|
||||
const rsNoCx = 215;
|
||||
const rsNodeY = rsForkHY + 32; // 522
|
||||
const rsNodeH = 54;
|
||||
|
||||
// ASAP-or-next-week fork (below rsYesCx=90)
|
||||
const prefForkHY = rsNodeY + rsNodeH + 22; // 598
|
||||
const asakCx = 48;
|
||||
const nwkCx = 138;
|
||||
const prefNodeY = prefForkHY + 32; // 630
|
||||
const prefNodeH = 62;
|
||||
|
||||
const totalH = prefNodeY + prefNodeH + 54 + 20 + 54 + 22; // time node + DB node + padding
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${totalH}`}
|
||||
className="w-full max-w-xl mx-auto"
|
||||
role="img"
|
||||
aria-label="LangGraph conversation flow diagram"
|
||||
>
|
||||
<defs>
|
||||
{/* Arrowhead marker */}
|
||||
<marker
|
||||
id="ah"
|
||||
markerWidth="10" markerHeight="7"
|
||||
refX="9" refY="3.5"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#9CA3AF" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* ── Node 1: Office sends reminder ─────────────────────────── */}
|
||||
<rect x={nx} y={n1y} width={nodeW} height={n1h} rx={8}
|
||||
fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={cx} y={n1y + 24} textAnchor="middle" fontSize={13}
|
||||
fontWeight="600" fill="#1D4ED8">Office sends reminder</text>
|
||||
<text x={cx} y={n1y + 42} textAnchor="middle" fontSize={10}
|
||||
fill="#93C5FD">Staff triggers the SMS</text>
|
||||
|
||||
{/* Arrow 1 → 2 */}
|
||||
<line x1={cx} y1={n1y + n1h} x2={cx} y2={n2y - 2}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* ── Node 2: Patient replies ───────────────────────────────── */}
|
||||
<rect x={nx} y={n2y} width={nodeW} height={n2h} rx={8}
|
||||
fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
||||
<text x={cx} y={n2y + 24} textAnchor="middle" fontSize={13}
|
||||
fontWeight="600" fill="#374151">Patient replies</text>
|
||||
<text x={cx} y={n2y + 42} textAnchor="middle" fontSize={10}
|
||||
fill="#9CA3AF">Any message triggers the AI</text>
|
||||
|
||||
{/* Arrow 2 → 3 */}
|
||||
<line x1={cx} y1={n2y + n2h} x2={cx} y2={n3y - 2}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* ── Node 3: AI introduces itself ──────────────────────────── */}
|
||||
<rect x={nx} y={n3y} width={nodeW} height={n3h} rx={8}
|
||||
fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={cx} y={n3y + 24} textAnchor="middle" fontSize={13}
|
||||
fontWeight="600" fill="#065F46">AI introduces itself</text>
|
||||
<text x={cx} y={n3y + 43} textAnchor="middle" fontSize={9}
|
||||
fill="#6B7280" fontStyle="italic">"Hi! My name is Lisa, the dedicated</text>
|
||||
<text x={cx} y={n3y + 57} textAnchor="middle" fontSize={9}
|
||||
fill="#6B7280" fontStyle="italic">AI assistant at {"{officeName}"}..."</text>
|
||||
|
||||
{/* ── Fork connectors ───────────────────────────────────────── */}
|
||||
{/* Vertical down from N3 */}
|
||||
<line x1={cx} y1={n3y + n3h} x2={cx} y2={forkHY}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Horizontal fork line */}
|
||||
<line x1={lcx} y1={forkHY} x2={rcx} y2={forkHY}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Left drop → NO branch */}
|
||||
<line x1={lcx} y1={forkHY} x2={lcx} y2={branchY - 2}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
{/* Right drop → YES branch */}
|
||||
<line x1={rcx} y1={forkHY} x2={rcx} y2={branchY - 2}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* ── YES / NO badges on the fork ───────────────────────────── */}
|
||||
{/* NO badge — left junction */}
|
||||
<rect x={lcx - 28} y={forkHY - 12} width={56} height={24} rx={12}
|
||||
fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={lcx} y={forkHY + 5} textAnchor="middle" fontSize={11}
|
||||
fontWeight="700" fill="#EA580C">NO</text>
|
||||
|
||||
{/* YES badge — right junction */}
|
||||
<rect x={rcx - 24} y={forkHY - 12} width={48} height={24} rx={12}
|
||||
fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rcx} y={forkHY + 5} textAnchor="middle" fontSize={11}
|
||||
fontWeight="700" fill="#15803D">YES</text>
|
||||
|
||||
{/* ── Left branch: NO → "Would you like to reschedule?" ───────── */}
|
||||
<rect x={lcx - branchW/2} y={branchY} width={branchW} height={branchH}
|
||||
rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={lcx} y={branchY+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">It is understandable!</text>
|
||||
<text x={lcx} y={branchY+40} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Would you like to</text>
|
||||
<text x={lcx} y={branchY+58} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">reschedule?</text>
|
||||
|
||||
{/* ── Right branch: YES → Confirm ───────────────────────────── */}
|
||||
<rect x={rcx - branchW/2} y={branchY} width={branchW} height={branchH}
|
||||
rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rcx} y={branchY+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#15803D">Thank you for</text>
|
||||
<text x={rcx} y={branchY+40} textAnchor="middle" fontSize={12} fontWeight="600" fill="#15803D">confirming!</text>
|
||||
<text x={rcx} y={branchY+60} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"See you on [date & time]"</text>
|
||||
|
||||
{/* ══════════ RESCHEDULING SUB-TREE ══════════════════════════════ */}
|
||||
|
||||
{/* Vertical from NO node bottom */}
|
||||
<line x1={lcx} y1={branchBottom} x2={lcx} y2={rsForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Horizontal YES/NO fork */}
|
||||
<line x1={rsYesCx} y1={rsForkHY} x2={rsNoCx} y2={rsForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={rsYesCx} y1={rsForkHY} x2={rsYesCx} y2={rsNodeY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
<line x1={rsNoCx} y1={rsForkHY} x2={rsNoCx} y2={rsNodeY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* YES / NO badges */}
|
||||
<rect x={rsYesCx-20} y={rsForkHY-11} width={40} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rsYesCx} y={rsForkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">YES</text>
|
||||
<rect x={rsNoCx-18} y={rsForkHY-11} width={36} height={22} rx={11} fill="#F9FAFB" stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<text x={rsNoCx} y={rsForkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#6B7280">NO</text>
|
||||
|
||||
{/* YES → "ASAP or next week?" node */}
|
||||
<rect x={rsYesCx-52} y={rsNodeY} width={104} height={rsNodeH} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={rsYesCx} y={rsNodeY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">ASAP or</text>
|
||||
<text x={rsYesCx} y={rsNodeY+32} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">next week?</text>
|
||||
<text x={rsYesCx} y={rsNodeY+46} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">(if Mon–Thu appt)</text>
|
||||
|
||||
{/* NO → polite close */}
|
||||
<rect x={rsNoCx-46} y={rsNodeY} width={92} height={rsNodeH} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
||||
<text x={rsNoCx} y={rsNodeY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#374151">No problem!</text>
|
||||
<text x={rsNoCx} y={rsNodeY+36} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Conversation ends</text>
|
||||
|
||||
{/* Vertical from "ASAP or next week?" down to preference fork */}
|
||||
<line x1={rsYesCx} y1={rsNodeY+rsNodeH} x2={rsYesCx} y2={prefForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* ASAP / Next week fork */}
|
||||
<line x1={asakCx} y1={prefForkHY} x2={nwkCx} y2={prefForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={asakCx} y1={prefForkHY} x2={asakCx} y2={prefNodeY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
<line x1={nwkCx} y1={prefForkHY} x2={nwkCx} y2={prefNodeY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* ASAP badge */}
|
||||
<rect x={asakCx-22} y={prefForkHY-11} width={44} height={22} rx={11} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={asakCx} y={prefForkHY+4} textAnchor="middle" fontSize={9} fontWeight="700" fill="#1D4ED8">ASAP</text>
|
||||
{/* Next week badge */}
|
||||
<rect x={nwkCx-30} y={prefForkHY-11} width={60} height={22} rx={11} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
|
||||
<text x={nwkCx} y={prefForkHY+4} textAnchor="middle" fontSize={9} fontWeight="700" fill="#6D28D9">Next week</text>
|
||||
|
||||
{/* ASAP → "Can you come tomorrow?" */}
|
||||
<rect x={asakCx-44} y={prefNodeY} width={88} height={prefNodeH} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={asakCx} y={prefNodeY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Can you come</text>
|
||||
<text x={asakCx} y={prefNodeY+30} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">tomorrow?</text>
|
||||
<text x={asakCx} y={prefNodeY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">YES → ask time</text>
|
||||
|
||||
{/* Next week → "Mon, Tue, or Wed?" */}
|
||||
<rect x={nwkCx-46} y={prefNodeY} width={92} height={prefNodeH} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
|
||||
<text x={nwkCx} y={prefNodeY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Mon, Tue,</text>
|
||||
<text x={nwkCx} y={prefNodeY+30} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">or Wed?</text>
|
||||
<text x={nwkCx} y={prefNodeY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">→ ask time</text>
|
||||
|
||||
{/* ── Time-slot node (shared by both paths) ─────────────────── */}
|
||||
{/* Left vertical from ASAP node */}
|
||||
<line x1={asakCx} y1={prefNodeY+prefNodeH} x2={asakCx} y2={prefNodeY+prefNodeH+18}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Right vertical from NextWeek node */}
|
||||
<line x1={nwkCx} y1={prefNodeY+prefNodeH} x2={nwkCx} y2={prefNodeY+prefNodeH+18}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Horizontal converge line */}
|
||||
<line x1={asakCx} y1={prefNodeY+prefNodeH+18} x2={nwkCx} y2={prefNodeY+prefNodeH+18}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
{/* Drop to time node */}
|
||||
<line x1={rsYesCx} y1={prefNodeY+prefNodeH+18} x2={rsYesCx} y2={prefNodeY+prefNodeH+36}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
|
||||
{/* Time-slot node */}
|
||||
<rect x={rsYesCx-52} y={prefNodeY+prefNodeH+40} width={104} height={54} rx={8}
|
||||
fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+58} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">Morning or</text>
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+72} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">afternoon?</text>
|
||||
|
||||
{/* DB update node (dashed) */}
|
||||
<line x1={rsYesCx} y1={prefNodeY+prefNodeH+94} x2={rsYesCx} y2={prefNodeY+prefNodeH+112}
|
||||
stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#ah)" />
|
||||
<rect x={rsYesCx-52} y={prefNodeY+prefNodeH+116} width={104} height={54} rx={8}
|
||||
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
|
||||
<rect x={rsYesCx-38} y={prefNodeY+prefNodeH+108} width={76} height={20} rx={10}
|
||||
fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+122} textAnchor="middle" fontSize={9} fontWeight="700" fill="#0369A1">DB Update</text>
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+138} textAnchor="middle" fontSize={10} fontWeight="600" fill="#0369A1">Appt. moved!</text>
|
||||
<text x={rsYesCx} y={prefNodeY+prefNodeH+154} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [day] at [time]"</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── New Patient / After-Hours flow diagram ───────────────────────────────────
|
||||
|
||||
function NewPatientFlow() {
|
||||
// ── Canvas ───────────────────────────────────────────────────────────────
|
||||
const W = 760;
|
||||
const cx = 380; // top-node horizontal center
|
||||
const nW = 220;
|
||||
const nx = cx - nW / 2; // 270
|
||||
|
||||
// ── Sequential top nodes ─────────────────────────────────────────────────
|
||||
const n1y = 15, n1h = 58;
|
||||
const n2y = 115, n2h = 58;
|
||||
const n3y = 215, n3h = 58;
|
||||
const n4y = 315, n4h = 60;
|
||||
|
||||
// ── Main fork ────────────────────────────────────────────────────────────
|
||||
const forkHY = n4y + n4h + 20; // 395
|
||||
const lcx = 165; // New Patient branch center
|
||||
const rcx = 590; // Existing Patient branch center
|
||||
const brW = 200;
|
||||
|
||||
// ── Main branch nodes ─────────────────────────────────────────────────────
|
||||
const brY = forkHY + 50; // 445
|
||||
const brH = 62;
|
||||
|
||||
// ── Sub-forks ─────────────────────────────────────────────────────────────
|
||||
const sfHY = brY + brH + 18; // 525
|
||||
const llcx = 100; const lrcx = 230; // New Patient sub-branches (centered on 165)
|
||||
const rlcx = 510; const rrcx = 660; // Existing Patient sub-branches
|
||||
|
||||
// ── Leaf nodes ────────────────────────────────────────────────────────────
|
||||
const leafY = sfHY + 46; // 571
|
||||
const leafW = 118;
|
||||
const leafH = 80;
|
||||
|
||||
// ── New patient: Selenium chain (below LL at cx=100) ─────────────────────
|
||||
const npSelY = leafY + leafH + 34; // 685 — Selenium node top
|
||||
const npSelW = 140;
|
||||
const npSelH = 56;
|
||||
const npResHY = npSelY + npSelH + 16; // 757 — result fork y
|
||||
const npActCx = 52; // ACTIVE result center (100-48)
|
||||
const npInaCx = 148; // INACTIVE result center (100+48)
|
||||
const npResY = npResHY + 34; // 791
|
||||
const npResW = 80;
|
||||
const npResH = 72;
|
||||
|
||||
// ── Existing patient: YES → MassHealth auto-check (below RL at cx=510) ──
|
||||
const exForkHY = leafY + leafH + 26; // 677
|
||||
const exMhCx = 445; // MassHealth sub-branch center
|
||||
const exOtherCx = 558; // Other insurance center
|
||||
const exCheckY = exForkHY + 34; // 711
|
||||
const exCheckW = 116;
|
||||
const exCheckH = 52;
|
||||
const exSelY = exCheckY + exCheckH + 18; // 781
|
||||
const exSelW = 128;
|
||||
const exSelH = 52;
|
||||
const exResHY = exSelY + exSelH + 16; // 849
|
||||
const exActCx = 401; // (445-44)
|
||||
const exInaCx = 489; // (445+44)
|
||||
const exLeafY = exResHY + 34; // 883
|
||||
const exLeafW = 80;
|
||||
const exLeafH = 62;
|
||||
|
||||
const totalH = Math.max(npResY + npResH, exLeafY + exLeafH) + 22;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${totalH}`}
|
||||
className="w-full max-w-2xl mx-auto"
|
||||
role="img"
|
||||
aria-label="New patient conversation flow diagram"
|
||||
>
|
||||
<defs>
|
||||
<marker id="nph" markerWidth="10" markerHeight="7"
|
||||
refX="9" refY="3.5" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#9CA3AF" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* ═══════════ TOP SEQUENCE ═══════════════════════════════════ */}
|
||||
|
||||
<rect x={nx} y={n1y} width={nW} height={n1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={cx} y={n1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Patient texts after hours</text>
|
||||
<text x={cx} y={n1y+38} textAnchor="middle" fontSize={10} fill="#93C5FD">or staff selects</text>
|
||||
<text x={cx} y={n1y+50} textAnchor="middle" fontSize={10} fill="#93C5FD">"Schedule a New Patient"</text>
|
||||
|
||||
<line x1={cx} y1={n1y+n1h} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={nx} y={n2y} width={nW} height={n2h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={cx} y={n2y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#065F46">AI sends New Patient Greeting</text>
|
||||
<text x={cx} y={n2y+40} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Hi! My name is Lisa..."</text>
|
||||
|
||||
<line x1={cx} y1={n2y+n2h} x2={cx} y2={n3y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#F9FAFB" stroke="#D1D5DB" strokeWidth={1.5} />
|
||||
<text x={cx} y={n3y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#374151">Patient replies</text>
|
||||
<text x={cx} y={n3y+40} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"I want an appointment / cleaning"</text>
|
||||
|
||||
<line x1={cx} y1={n3y+n3h} x2={cx} y2={n4y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={nx} y={n4y} width={nW} height={n4h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={cx} y={n4y+24} textAnchor="middle" fontSize={12} fontWeight="600" fill="#065F46">AI: New or existing patient?</text>
|
||||
<text x={cx} y={n4y+42} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"Are you a new or existing patient?"</text>
|
||||
|
||||
{/* ═══════════ MAIN FORK ══════════════════════════════════════ */}
|
||||
|
||||
<line x1={cx} y1={n4y+n4h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={lcx} y1={forkHY} x2={rcx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={lcx} y1={forkHY} x2={lcx} y2={brY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={rcx} y1={forkHY} x2={rcx} y2={brY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={lcx-42} y={forkHY-13} width={84} height={24} rx={12} fill="#EEF2FF" stroke="#6366F1" strokeWidth={1.5} />
|
||||
<text x={lcx} y={forkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#4338CA">New Patient</text>
|
||||
<rect x={rcx-50} y={forkHY-13} width={100} height={24} rx={12} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
|
||||
<text x={rcx} y={forkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#6D28D9">Existing Patient</text>
|
||||
|
||||
{/* ═══════════ LEFT BRANCH — NEW PATIENT ══════════════════════ */}
|
||||
|
||||
<rect x={lcx-brW/2} y={brY} width={brW} height={brH} rx={8} fill="#EEF2FF" stroke="#6366F1" strokeWidth={1.5} />
|
||||
<text x={lcx} y={brY+22} textAnchor="middle" fontSize={11} fontWeight="600" fill="#3730A3">Do you have any</text>
|
||||
<text x={lcx} y={brY+38} textAnchor="middle" fontSize={11} fontWeight="600" fill="#3730A3">dental insurance?</text>
|
||||
|
||||
<line x1={lcx} y1={brY+brH} x2={lcx} y2={sfHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={llcx} y1={sfHY} x2={lrcx} y2={sfHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={llcx} y1={sfHY} x2={llcx} y2={leafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={lrcx} y1={sfHY} x2={lrcx} y2={leafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={llcx-36} y={sfHY-11} width={72} height={22} rx={11} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={llcx} y={sfHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#065F46">MassHealth</text>
|
||||
<rect x={lrcx-32} y={sfHY-11} width={64} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={lrcx} y={sfHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">No ins.</text>
|
||||
|
||||
{/* NP: MassHealth leaf — ask for ID+DOB */}
|
||||
<rect x={llcx-leafW/2} y={leafY} width={leafW} height={leafH} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={llcx} y={leafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">Check MassHealth</text>
|
||||
<text x={llcx} y={leafY+34} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"Text me your</text>
|
||||
<text x={llcx} y={leafY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">Member ID & DOB"</text>
|
||||
|
||||
{/* NP: No insurance leaf */}
|
||||
<rect x={lrcx-leafW/2} y={leafY} width={leafW} height={leafH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={lrcx} y={leafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Schedule</text>
|
||||
<text x={lrcx} y={leafY+34} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"When would</text>
|
||||
<text x={lrcx} y={leafY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">you like to come?"</text>
|
||||
|
||||
{/* NP: Selenium check (patient texts ID+DOB) */}
|
||||
<line x1={llcx} y1={leafY+leafH} x2={llcx} y2={npSelY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<rect x={llcx-npSelW/2} y={npSelY} width={npSelW} height={npSelH} rx={8}
|
||||
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
|
||||
<rect x={llcx-40} y={npSelY-11} width={80} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||
<text x={llcx} y={npSelY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Selenium</text>
|
||||
<text x={llcx} y={npSelY+20} textAnchor="middle" fontSize={10} fontWeight="600" fill="#0369A1">MassHealth Portal</text>
|
||||
<text x={llcx} y={npSelY+36} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Auto-checks eligibility</text>
|
||||
<text x={llcx} y={npSelY+48} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">→ result SMS to patient</text>
|
||||
|
||||
{/* NP: Post-Selenium ACTIVE/INACTIVE fork */}
|
||||
<line x1={llcx} y1={npSelY+npSelH} x2={llcx} y2={npResHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={npActCx} y1={npResHY} x2={npInaCx} y2={npResHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={npActCx} y1={npResHY} x2={npActCx} y2={npResY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={npInaCx} y1={npResHY} x2={npInaCx} y2={npResY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={npActCx-28} y={npResHY-11} width={56} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={npActCx} y={npResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">ACTIVE</text>
|
||||
<rect x={npInaCx-34} y={npResHY-11} width={68} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={npInaCx} y={npResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">INACTIVE</text>
|
||||
|
||||
<rect x={npActCx-npResW/2} y={npResY} width={npResW} height={npResH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={npActCx} y={npResY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Check-up or</text>
|
||||
<text x={npActCx} y={npResY+30} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">tooth problem?</text>
|
||||
<text x={npActCx} y={npResY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">→ book appt</text>
|
||||
|
||||
<rect x={npInaCx-npResW/2} y={npResY} width={npResW} height={npResH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={npInaCx} y={npResY+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Inactive.</text>
|
||||
<text x={npInaCx} y={npResY+30} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Self-pay?</text>
|
||||
<text x={npInaCx} y={npResY+50} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">YES/NO</text>
|
||||
|
||||
{/* ═══════════ RIGHT BRANCH — EXISTING PATIENT ════════════════ */}
|
||||
|
||||
<rect x={rcx-brW/2} y={brY} width={brW} height={brH} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
|
||||
<text x={rcx} y={brY+22} textAnchor="middle" fontSize={11} fontWeight="600" fill="#5B21B6">Do you still have</text>
|
||||
<text x={rcx} y={brY+38} textAnchor="middle" fontSize={11} fontWeight="600" fill="#5B21B6">the same insurance?</text>
|
||||
|
||||
<line x1={rcx} y1={brY+brH} x2={rcx} y2={sfHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={rlcx} y1={sfHY} x2={rrcx} y2={sfHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={rlcx} y1={sfHY} x2={rlcx} y2={leafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={rrcx} y1={sfHY} x2={rrcx} y2={leafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={rlcx-20} y={sfHY-11} width={40} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rlcx} y={sfHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">YES</text>
|
||||
<rect x={rrcx-20} y={sfHY-11} width={40} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={rrcx} y={sfHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">NO</text>
|
||||
|
||||
{/* EP: YES — "Same insurance confirmed" */}
|
||||
<rect x={rlcx-leafW/2} y={leafY} width={leafW} height={leafH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={rlcx} y={leafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Same insurance</text>
|
||||
<text x={rlcx} y={leafY+34} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">MassHealth → auto-check</text>
|
||||
<text x={rlcx} y={leafY+48} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Other → schedule</text>
|
||||
|
||||
{/* EP: NO — Transfer */}
|
||||
<rect x={rrcx-leafW/2} y={leafY} width={leafW} height={leafH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={rrcx} y={leafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Transfer</text>
|
||||
<text x={rrcx} y={leafY+34} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"Our staff will</text>
|
||||
<text x={rrcx} y={leafY+48} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">assist you"</text>
|
||||
|
||||
{/* EP: YES → MassHealth sub-fork */}
|
||||
<line x1={rlcx} y1={leafY+leafH} x2={rlcx} y2={exForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={exMhCx} y1={exForkHY} x2={exOtherCx} y2={exForkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={exMhCx} y1={exForkHY} x2={exMhCx} y2={exCheckY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={exOtherCx} y1={exForkHY} x2={exOtherCx} y2={exCheckY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={exMhCx-36} y={exForkHY-11} width={72} height={22} rx={11} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={exMhCx} y={exForkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#065F46">MassHealth</text>
|
||||
<rect x={exOtherCx-32} y={exForkHY-11} width={64} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||
<text x={exOtherCx} y={exForkHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Other ins.</text>
|
||||
|
||||
{/* EP: MassHealth — "Checking 30-60s" node */}
|
||||
<rect x={exMhCx-exCheckW/2} y={exCheckY} width={exCheckW} height={exCheckH} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={exMhCx} y={exCheckY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#065F46">Wait 30-60 secs…</text>
|
||||
<text x={exMhCx} y={exCheckY+34} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Using saved ID & DOB</text>
|
||||
|
||||
{/* EP: Other insurance — schedule directly */}
|
||||
<rect x={exOtherCx-exCheckW/2} y={exCheckY} width={exCheckW} height={exCheckH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={exOtherCx} y={exCheckY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Schedule</text>
|
||||
<text x={exOtherCx} y={exCheckY+34} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">"When to come in?"</text>
|
||||
|
||||
{/* EP: MassHealth → Selenium */}
|
||||
<line x1={exMhCx} y1={exCheckY+exCheckH} x2={exMhCx} y2={exSelY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<rect x={exMhCx-exSelW/2} y={exSelY} width={exSelW} height={exSelH} rx={8}
|
||||
fill="#F0F9FF" stroke="#0EA5E9" strokeWidth={1.5} strokeDasharray="5 3" />
|
||||
<rect x={exMhCx-38} y={exSelY-11} width={76} height={22} rx={11} fill="#E0F2FE" stroke="#0EA5E9" strokeWidth={1.5} />
|
||||
<text x={exMhCx} y={exSelY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#0369A1">Selenium</text>
|
||||
<text x={exMhCx} y={exSelY+20} textAnchor="middle" fontSize={10} fontWeight="600" fill="#0369A1">MassHealth Portal</text>
|
||||
<text x={exMhCx} y={exSelY+36} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Auto-checks eligibility</text>
|
||||
|
||||
{/* EP: ACTIVE / INACTIVE fork */}
|
||||
<line x1={exMhCx} y1={exSelY+exSelH} x2={exMhCx} y2={exResHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={exActCx} y1={exResHY} x2={exInaCx} y2={exResHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={exActCx} y1={exResHY} x2={exActCx} y2={exLeafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
<line x1={exInaCx} y1={exResHY} x2={exInaCx} y2={exLeafY-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#nph)" />
|
||||
|
||||
<rect x={exActCx-28} y={exResHY-11} width={56} height={22} rx={11} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={exActCx} y={exResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#15803D">ACTIVE</text>
|
||||
<rect x={exInaCx-34} y={exResHY-11} width={68} height={22} rx={11} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={exInaCx} y={exResHY+4} textAnchor="middle" fontSize={10} fontWeight="700" fill="#C2410C">INACTIVE</text>
|
||||
|
||||
<rect x={exActCx-exLeafW/2} y={exLeafY} width={exLeafW} height={exLeafH} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={exActCx} y={exLeafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">When to</text>
|
||||
<text x={exActCx} y={exLeafY+32} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">come in?</text>
|
||||
<text x={exActCx} y={exLeafY+50} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">→ book appt</text>
|
||||
|
||||
<rect x={exInaCx-exLeafW/2} y={exLeafY} width={exLeafW} height={exLeafH} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={exInaCx} y={exLeafY+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Inactive.</text>
|
||||
<text x={exInaCx} y={exLeafY+32} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Self-pay?</text>
|
||||
<text x={exInaCx} y={exLeafY+50} textAnchor="middle" fontSize={9} fill="#9CA3AF" fontStyle="italic">YES/NO</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function AiChatSettingsCard() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
|
||||
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
|
||||
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const { data: officeContact } = useQuery<OfficeContact | null>({
|
||||
queryKey: ["/api/office-contact"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/office-contact");
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: templates, isLoading } = useQuery<AiChatTemplates>({
|
||||
queryKey: ["/api/ai/chat-templates"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/ai/chat-templates");
|
||||
if (!res.ok) throw new Error("Failed to load templates");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Seed local state from server on first load only
|
||||
useEffect(() => {
|
||||
if (templates && !initialized.current) {
|
||||
initialized.current = true;
|
||||
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
|
||||
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
|
||||
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
|
||||
}
|
||||
}, [templates]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (data: AiChatTemplates) => {
|
||||
const res = await apiRequest("PUT", "/api/ai/chat-templates", data);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.message || "Failed to save templates");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/chat-templates"] });
|
||||
toast({ title: "Templates saved", description: "AI chat templates have been updated." });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: "Error", description: err?.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate({
|
||||
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
||||
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
||||
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
||||
});
|
||||
};
|
||||
|
||||
const officeName = officeContact?.officeName?.trim() || "";
|
||||
|
||||
const templateFields = [
|
||||
{
|
||||
key: "reminder",
|
||||
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
|
||||
label: "Appointment Reminder Reply",
|
||||
description: "Sent when the AI first introduces itself after an appointment reminder.",
|
||||
value: reminderGreeting,
|
||||
onChange: setReminderGreeting,
|
||||
placeholder: DEFAULTS.reminderGreeting,
|
||||
},
|
||||
{
|
||||
key: "newPatient",
|
||||
icon: <UserPlus className="h-4 w-4 text-primary" />,
|
||||
label: "New Patient Greeting",
|
||||
description: "Sent when a new patient texts in for the first time.",
|
||||
value: newPatientGreeting,
|
||||
onChange: setNewPatientGreeting,
|
||||
placeholder: DEFAULTS.newPatientGreeting,
|
||||
},
|
||||
{
|
||||
key: "general",
|
||||
icon: <MessageCircle className="h-4 w-4 text-primary" />,
|
||||
label: "General Fallback",
|
||||
description: "Used when the AI cannot determine the context of the patient's message.",
|
||||
value: generalFallback,
|
||||
onChange: setGeneralFallback,
|
||||
placeholder: DEFAULTS.generalFallback,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── Section 1: Chat Templates ────────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Chat Templates</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize how the AI assistant introduces itself to patients. Use{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
|
||||
as a placeholder — it will be replaced with your dental office name automatically.
|
||||
</p>
|
||||
|
||||
{officeName && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
|
||||
<span className="font-medium text-foreground">"{officeName}"</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading templates...</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{templateFields.map((f) => (
|
||||
<div key={f.key} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{f.icon}
|
||||
<span className="text-sm font-medium">{f.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{f.description}</p>
|
||||
<Textarea
|
||||
value={f.value}
|
||||
onChange={(e) => f.onChange(e.target.value)}
|
||||
placeholder={f.placeholder}
|
||||
rows={3}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
{officeName && f.value.includes("{officeName}") && (
|
||||
<p className="text-xs text-muted-foreground italic pl-1">
|
||||
Preview: {previewTemplate(f.value, officeName)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white"
|
||||
>
|
||||
{saveMutation.isPending ? "Saving..." : "Save Templates"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setReminderGreeting(DEFAULTS.reminderGreeting);
|
||||
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
|
||||
setGeneralFallback(DEFAULTS.generalFallback);
|
||||
}}
|
||||
>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Section 2: LangGraph Flow ────────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitFork className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">LangGraph Settings</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visual diagrams of each AI conversation graph — select a flow below to inspect its node sequence.
|
||||
</p>
|
||||
|
||||
{/* Shared legend */}
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{[
|
||||
{ bg: "bg-blue-100", border: "border-blue-400", label: "Staff action" },
|
||||
{ bg: "bg-gray-100", border: "border-gray-400", label: "Patient action" },
|
||||
{ bg: "bg-emerald-50", border: "border-emerald-400",label: "AI node" },
|
||||
{ bg: "bg-indigo-50", border: "border-indigo-400", label: "New patient branch" },
|
||||
{ bg: "bg-violet-50", border: "border-violet-400", label: "Existing patient" },
|
||||
{ bg: "bg-green-50", border: "border-green-400", label: "Confirmed / Schedule"},
|
||||
{ bg: "bg-orange-50", border: "border-orange-400", label: "Reschedule / Transfer"},
|
||||
].map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-3 h-3 rounded-sm ${l.bg} border ${l.border}`} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="reminder">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="reminder" className="flex-1 text-xs">
|
||||
Appointment Reminder Flow
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="new_patient" className="flex-1 text-xs">
|
||||
New Patient / After-Hours Flow
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="reminder">
|
||||
<div className="overflow-x-auto rounded-lg border bg-gray-50/50 p-4 mt-3">
|
||||
<LangGraphFlow />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new_patient">
|
||||
<div className="overflow-x-auto rounded-lg border bg-gray-50/50 p-4 mt-3">
|
||||
<NewPatientFlow />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 pl-1">
|
||||
Any reply not matching a known intent returns: "Our office staff will assist you shortly."
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
apps/Frontend/src/components/settings/ai-chat-templates-card.tsx
Normal file
209
apps/Frontend/src/components/settings/ai-chat-templates-card.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info } from "lucide-react";
|
||||
|
||||
type AiChatTemplates = {
|
||||
reminderGreeting: string;
|
||||
newPatientGreeting: string;
|
||||
generalFallback: string;
|
||||
};
|
||||
|
||||
type OfficeContact = {
|
||||
officeName?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
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. How can I help you today?",
|
||||
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?",
|
||||
generalFallback:
|
||||
"How can I help you today?",
|
||||
};
|
||||
|
||||
function preview(text: string, officeName: string) {
|
||||
return text.replace(/\{officeName\}/g, officeName || "your dental office");
|
||||
}
|
||||
|
||||
export function AiChatTemplatesCard() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
|
||||
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
|
||||
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const { data: officeContact } = useQuery<OfficeContact | null>({
|
||||
queryKey: ["/api/office-contact"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/office-contact");
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: templates, isLoading } = useQuery<AiChatTemplates>({
|
||||
queryKey: ["/api/ai/chat-templates"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/ai/chat-templates");
|
||||
if (!res.ok) throw new Error("Failed to load templates");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: Infinity, // never silently refetch and overwrite user edits
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Seed state from server on first successful load only
|
||||
useEffect(() => {
|
||||
if (templates && !initialized.current) {
|
||||
initialized.current = true;
|
||||
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
|
||||
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
|
||||
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
|
||||
}
|
||||
}, [templates]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (data: AiChatTemplates) => {
|
||||
const res = await apiRequest("PUT", "/api/ai/chat-templates", data);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.message || "Failed to save templates");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/chat-templates"] });
|
||||
toast({ title: "Templates saved", description: "AI chat templates have been updated." });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({ title: "Error", description: err?.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate({
|
||||
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
||||
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
||||
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
||||
});
|
||||
};
|
||||
|
||||
const officeName = officeContact?.officeName?.trim() || "";
|
||||
|
||||
const templates_list = [
|
||||
{
|
||||
key: "reminder" as const,
|
||||
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
|
||||
label: "Appointment Reminder Reply",
|
||||
description: "Sent when the AI introduces itself after the office sends an appointment reminder.",
|
||||
value: reminderGreeting,
|
||||
onChange: setReminderGreeting,
|
||||
placeholder: DEFAULTS.reminderGreeting,
|
||||
},
|
||||
{
|
||||
key: "newPatient" as const,
|
||||
icon: <UserPlus className="h-4 w-4 text-primary" />,
|
||||
label: "New Patient Greeting",
|
||||
description: "Sent when a new patient texts in for the first time.",
|
||||
value: newPatientGreeting,
|
||||
onChange: setNewPatientGreeting,
|
||||
placeholder: DEFAULTS.newPatientGreeting,
|
||||
},
|
||||
{
|
||||
key: "general" as const,
|
||||
icon: <MessageCircle className="h-4 w-4 text-primary" />,
|
||||
label: "General Fallback",
|
||||
description: "Used when the AI cannot determine the context of the patient's message.",
|
||||
value: generalFallback,
|
||||
onChange: setGeneralFallback,
|
||||
placeholder: DEFAULTS.generalFallback,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">AI Chat Templates</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize how your AI assistant introduces itself and responds to patients. Use{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
|
||||
as a placeholder — it will be replaced automatically with your dental office name.
|
||||
</p>
|
||||
|
||||
{/* Office name hint */}
|
||||
{officeName && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
|
||||
<Info className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
|
||||
<span className="font-medium text-foreground">"{officeName}"</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading templates...</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{templates_list.map((t) => (
|
||||
<div key={t.key} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{t.icon}
|
||||
<span className="text-sm font-medium">{t.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t.description}</p>
|
||||
<Textarea
|
||||
value={t.value}
|
||||
onChange={(e) => t.onChange(e.target.value)}
|
||||
placeholder={t.placeholder}
|
||||
rows={3}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
{/* Live preview */}
|
||||
{officeName && t.value.includes("{officeName}") && (
|
||||
<p className="text-xs text-muted-foreground italic pl-1">
|
||||
Preview: {preview(t.value, officeName)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white"
|
||||
>
|
||||
{saveMutation.isPending ? "Saving..." : "Save Templates"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setReminderGreeting(DEFAULTS.reminderGreeting);
|
||||
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
|
||||
setGeneralFallback(DEFAULTS.generalFallback);
|
||||
}}
|
||||
>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Phone,
|
||||
PhoneCall,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
X,
|
||||
MoonStar,
|
||||
} from "lucide-react";
|
||||
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
|
||||
import { MessageThread } from "@/components/patient-connection/message-thread";
|
||||
@@ -35,8 +37,36 @@ export default function PatientConnectionPage() {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [isSmsDialogOpen, setIsSmsDialogOpen] = useState(false);
|
||||
const [showMessaging, setShowMessaging] = useState(false);
|
||||
const [afterHoursEnabled, setAfterHoursEnabled] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useQuery<{ enabled: boolean }>({
|
||||
queryKey: ["/api/twilio/after-hours-handoff"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/twilio/after-hours-handoff");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data: { enabled: boolean }) => setAfterHoursEnabled(data.enabled),
|
||||
} as any);
|
||||
|
||||
const afterHoursMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) =>
|
||||
apiRequest("PUT", "/api/twilio/after-hours-handoff", { enabled }),
|
||||
onSuccess: (_: any, enabled: boolean) => {
|
||||
toast({
|
||||
title: enabled ? "After-hours AI enabled" : "After-hours AI disabled",
|
||||
description: enabled
|
||||
? "AI will automatically handle messages outside office hours."
|
||||
: "After-hours messages will not receive an automatic AI reply.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAfterHoursToggle = (enabled: boolean) => {
|
||||
setAfterHoursEnabled(enabled);
|
||||
afterHoursMutation.mutate(enabled);
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
@@ -175,6 +205,16 @@ export default function PatientConnectionPage() {
|
||||
Search and communicate with patients
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* After-hours AI toggle */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-lg border bg-white shadow-sm">
|
||||
<MoonStar className={`h-4 w-4 ${afterHoursEnabled ? "text-primary" : "text-muted-foreground"}`} />
|
||||
<span className="text-sm font-medium">Hand off to AI after hours</span>
|
||||
<Switch
|
||||
checked={afterHoursEnabled}
|
||||
onCheckedChange={handleAfterHoursToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { OfficeHoursCard } from "@/components/settings/office-hours-card";
|
||||
import { OfficeContactCard } from "@/components/settings/office-contact-card";
|
||||
import { ProcedureTimeslotCard } from "@/components/settings/procedure-timeslot-card";
|
||||
import { InsuranceContactCard } from "@/components/settings/insurance-contact-card";
|
||||
import { AiChatSettingsCard } from "@/components/settings/ai-chat-settings-card";
|
||||
|
||||
type SectionId =
|
||||
| "staff"
|
||||
@@ -29,6 +30,7 @@ type SectionId =
|
||||
| "programs"
|
||||
| "twilio"
|
||||
| "ai"
|
||||
| "aichat"
|
||||
| "officehours"
|
||||
| "officecontact"
|
||||
| "proceduretimeslot"
|
||||
@@ -259,6 +261,9 @@ export default function SettingsPage() {
|
||||
case "ai":
|
||||
return <AiSettingsCard />;
|
||||
|
||||
case "aichat":
|
||||
return <AiChatSettingsCard />;
|
||||
|
||||
case "officehours":
|
||||
return <OfficeHoursCard />;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user