- Add llm-factory.ts: unified LLM provider abstraction (Google/Claude/OpenAI) - Install @langchain/anthropic and @langchain/openai packages - resolveAiProvider picks active provider from DB settings (Claude > OpenAI > Google) - All AI graphs (reminder, new-patient, reschedule, internal-chat) now accept provider+model params - Add claudeAiModel, openAiModel, googleAiModel columns to ai_settings table - New PUT /api/ai/provider-model route to save selected model per provider - UI model dropdowns for Claude (Haiku/Sonnet/Opus), OpenAI (GPT-5.x series), Google (Gemini 2.5/3.x) - Google AI section also gets model selector alongside existing API key field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
630 lines
31 KiB
TypeScript
630 lines
31 KiB
TypeScript
import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
|
|
import { getLlm, type AiProvider } from "./llm-factory";
|
|
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>(),
|
|
generalFallback: 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|old patient|old\b|been there|have been|already|before|i have been|returning|came before|i was there|previous|prior|past patient/i.test(text);
|
|
}
|
|
|
|
function hasMassHealth(text: string): boolean {
|
|
return /masshealth|mass health|medicaid|masscare/i.test(text);
|
|
}
|
|
|
|
function hasInsurance(text: string): boolean {
|
|
return /yes|i have|my insurance|i do|have insurance|insured/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|no,? i|not insured/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);
|
|
}
|
|
|
|
function saysYes(text: string): boolean {
|
|
return /\byes\b|yeah|yep|sure|ok\b|okay|of course|absolutely|please|si\b|sí|oui|wi\b|نعم|好的/i.test(text);
|
|
}
|
|
|
|
function saysNo(text: string): boolean {
|
|
return /\bno\b|nope|nah|not really|i don't|don't|no i|لا|非|no,/i.test(text);
|
|
}
|
|
|
|
// ── LLM reply helper ──────────────────────────────────────────────────────────
|
|
|
|
async function llmReply(
|
|
system: string,
|
|
userMsg: string,
|
|
fallback: string,
|
|
apiKey: string,
|
|
provider: AiProvider = "google",
|
|
model?: string
|
|
): Promise<string> {
|
|
try {
|
|
const llm = getLlm(provider, apiKey, model);
|
|
const res = await llm.invoke([
|
|
{ role: "system", content: system },
|
|
{ role: "user", content: userMsg },
|
|
]);
|
|
return String(res.content).trim() || fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
// ── Graph nodes ───────────────────────────────────────────────────────────────
|
|
|
|
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 (hasInsurance(text)) intent = "has_insurance";
|
|
|
|
} else if (stage === "asked_insurance_type") {
|
|
if (hasMassHealth(text)) intent = "masshealth";
|
|
else intent = "other_insurance";
|
|
|
|
} else if (stage === "asked_masshealth_check_consent") {
|
|
if (saysYes(text)) intent = "yes";
|
|
else intent = "no";
|
|
|
|
} 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";
|
|
|
|
} else if (stage === "asked_other_insurance_after_inactive") {
|
|
if (saysYes(text) && !saysNo(text)) intent = "yes";
|
|
else if (saysNo(text)) intent = "no";
|
|
|
|
} else if (stage === "collecting_contact_info") {
|
|
intent = "contact_info_received";
|
|
}
|
|
|
|
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_check_consent"
|
|
: intent === "no_insurance" ? "ask_appointment_time"
|
|
: intent === "has_insurance" ? "ask_insurance_type"
|
|
: "transfer";
|
|
|
|
if (stage === "asked_insurance_type")
|
|
return intent === "masshealth" ? "ask_masshealth_check_consent" : "ask_appointment_time";
|
|
|
|
if (stage === "asked_masshealth_check_consent")
|
|
return intent === "yes" ? "ask_masshealth_info" : "ask_appointment_time";
|
|
|
|
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";
|
|
|
|
if (stage === "asked_other_insurance_after_inactive")
|
|
return intent === "yes" ? "transfer"
|
|
: intent === "no" ? "ask_contact_info"
|
|
: "transfer";
|
|
|
|
if (stage === "collecting_contact_info") return "acknowledge_contact_info";
|
|
|
|
return "transfer";
|
|
}
|
|
|
|
// ── Response nodes ────────────────────────────────────────────────────────────
|
|
|
|
async function askNewOrExistingNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
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, provider, model
|
|
)
|
|
: 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 provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
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, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "asked_new_patient_insurance" };
|
|
}
|
|
|
|
async function askInsuranceTypeNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
const fallbacks: Record<string, string> = {
|
|
English: "What kind of insurance do you have?",
|
|
Spanish: "¿Qué tipo de seguro tiene?",
|
|
Portuguese: "Que tipo de plano de saúde você tem?",
|
|
Mandarin: "您有什么类型的保险?",
|
|
Cantonese: "您有什麼類型的保險?",
|
|
Arabic: "ما نوع التأمين الذي لديك؟",
|
|
"Haitian Creole": "Ki kalite asirans ou genyen?",
|
|
};
|
|
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
|
|
|
const reply = apiKey
|
|
? await llmReply(
|
|
`You are a friendly dental office AI assistant. The patient confirmed they have insurance. Ask them in ${lang} what kind of insurance they have. One sentence, no formatting.`,
|
|
`Patient has insurance. Ask what kind.`,
|
|
fallback, apiKey, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "asked_insurance_type" };
|
|
}
|
|
|
|
async function askMassHealthCheckConsentNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
const fallbacks: Record<string, string> = {
|
|
English: "Do you want to check your MassHealth insurance now?",
|
|
Spanish: "¿Desea verificar su seguro MassHealth ahora?",
|
|
Portuguese: "Você gostaria de verificar sua cobertura MassHealth agora?",
|
|
Mandarin: "您想现在查询您的MassHealth保险吗?",
|
|
Cantonese: "您想現在查詢您的MassHealth保險嗎?",
|
|
Arabic: "هل تريد التحقق من تأمين MassHealth الخاص بك الآن؟",
|
|
"Haitian Creole": "Èske ou vle verifye asirans MassHealth ou kounye a?",
|
|
};
|
|
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
|
|
|
const reply = apiKey
|
|
? await llmReply(
|
|
`You are a friendly dental office AI assistant. The patient has MassHealth insurance. Ask them in ${lang} if they would like to check their MassHealth coverage right now. One sentence, no formatting.`,
|
|
`Patient has MassHealth. Ask if they want to check it now.`,
|
|
fallback, apiKey, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "asked_masshealth_check_consent" };
|
|
}
|
|
|
|
async function askMassHealthInfoNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
const fallbacks: Record<string, string> = {
|
|
English: "Please send me your MassHealth Member ID and date of birth so I can check your coverage.",
|
|
Spanish: "Por favor envíeme su número de miembro de MassHealth y su fecha de nacimiento para verificar su cobertura.",
|
|
Portuguese: "Por favor envie seu número de membro MassHealth e data de nascimento para verificar sua cobertura.",
|
|
Mandarin: "请发送您的MassHealth会员ID和出生日期,以便我查询您的保险。",
|
|
Cantonese: "請傳送您的MassHealth會員ID和出生日期,以便我查詢您的保險。",
|
|
Arabic: "من فضلك أرسل لي رقم عضوية MassHealth وتاريخ ميلادك حتى أتحقق من تغطيتك.",
|
|
"Haitian Creole": "Tanpri voye ID manm MassHealth ou ak dat nesans ou pou mwen ka verifye kouvèti ou.",
|
|
};
|
|
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
|
|
|
const reply = apiKey
|
|
? await llmReply(
|
|
`You are a friendly dental office AI assistant. The patient wants to check their MassHealth coverage. Ask them in ${lang} to provide their MassHealth Member ID and date of birth. 1-2 sentences, no formatting.`,
|
|
`Patient agreed to MassHealth check. Ask for member ID and DOB.`,
|
|
fallback, apiKey, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "awaiting_masshealth_info" };
|
|
}
|
|
|
|
async function askExistingInsuranceNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
const fallbacks: Record<string, string> = {
|
|
English: "Got it — you are an existing patient! Do you still have the same insurance on file?",
|
|
Spanish: "¡Entendido — usted es un paciente existente! ¿Sigue teniendo el mismo seguro registrado?",
|
|
Portuguese: "Entendido — você é um paciente existente! Você ainda tem o mesmo plano registrado?",
|
|
Mandarin: "好的——您是现有患者!您还有相同的保险记录吗?",
|
|
Cantonese: "好的——您是現有病人!您仍然有相同的保險記錄嗎?",
|
|
Arabic: "حسناً — أنت مريض حالي! هل لا تزال تمتلك نفس التأمين المسجل لدينا؟",
|
|
"Haitian Creole": "Konprann — ou se yon pasyan egzistan! Èske ou toujou gen menm asirans nou gen anrejistreman?",
|
|
};
|
|
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
|
|
|
const reply = apiKey
|
|
? await llmReply(
|
|
`You are a friendly dental office AI assistant. The patient confirmed they are an existing patient. In ${lang}, acknowledge that they are an existing patient and then ask if they still have the same dental insurance on file. 1-2 sentences, no formatting.`,
|
|
`Existing patient confirmed. Acknowledge and ask about insurance.`,
|
|
fallback, apiKey, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "asked_existing_insurance" };
|
|
}
|
|
|
|
async function askAppointmentTimeNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
const fallbacks: Record<string, string> = {
|
|
English: "When would you like to come in for your appointment?",
|
|
Spanish: "¿Cuándo le gustaría venir para su cita?",
|
|
Portuguese: "Quando você gostaria de agendar sua 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} what date and time they would prefer for their appointment. One sentence, no formatting.`,
|
|
`Ask when to schedule.`,
|
|
fallback, apiKey, provider, model
|
|
)
|
|
: 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 provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
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, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "done" };
|
|
}
|
|
|
|
async function handleAppointmentPreferenceNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
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, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "asked_appointment_time" };
|
|
}
|
|
|
|
async function handleSelfPayNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
const text = state.message.toLowerCase();
|
|
|
|
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" };
|
|
}
|
|
|
|
return { reply: transferMsg(lang), nextStage: "done" };
|
|
}
|
|
|
|
async function askContactInfoNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
const fallbacks: Record<string, string> = {
|
|
English: "Please leave your name and phone number. Our receptionist will contact you as soon as possible.",
|
|
Spanish: "Por favor déjenos su nombre y número de teléfono. Nuestra recepcionista se comunicará con usted lo antes posible.",
|
|
Portuguese: "Por favor deixe seu nome e número de telefone. Nossa recepcionista entrará em contato o mais breve possível.",
|
|
Mandarin: "请留下您的姓名和电话号码,我们的前台将尽快与您联系。",
|
|
Cantonese: "請留下您的姓名和電話號碼,我們的接待員將盡快與您聯絡。",
|
|
Arabic: "يرجى ترك اسمك ورقم هاتفك. ستتواصل معك موظفة الاستقبال في أقرب وقت ممكن.",
|
|
"Haitian Creole": "Tanpri kite non ou ak nimewo telefòn ou. Resepsyonis nou an pral kontakte ou pi vit posib.",
|
|
};
|
|
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
|
|
|
const reply = apiKey
|
|
? await llmReply(
|
|
`You are a friendly dental office AI assistant. The patient's MassHealth is inactive and they have no other insurance. In ${lang}, politely ask them to leave their name and phone number so the receptionist can contact them. 1-2 sentences, no formatting.`,
|
|
`MassHealth inactive, no other insurance. Ask for name and phone.`,
|
|
fallback, apiKey, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "collecting_contact_info" };
|
|
}
|
|
|
|
async function acknowledgeContactInfoNode(state: GraphStateType, config: any) {
|
|
const lang = state.language || "English";
|
|
const apiKey: string | undefined = config?.configurable?.apiKey;
|
|
const provider: AiProvider = config?.configurable?.provider ?? "google";
|
|
const model: string | undefined = config?.configurable?.model;
|
|
|
|
const fallbacks: Record<string, string> = {
|
|
English: "Thank you! Our receptionist will reach out to you shortly.",
|
|
Spanish: "¡Gracias! Nuestra recepcionista se pondrá en contacto con usted en breve.",
|
|
Portuguese: "Obrigado! Nossa recepcionista entrará em contato com você em breve.",
|
|
Mandarin: "谢谢!我们的前台将很快与您联系。",
|
|
Cantonese: "多謝!我們的接待員將很快與您聯絡。",
|
|
Arabic: "شكراً! ستتواصل معك موظفة الاستقبال قريباً.",
|
|
"Haitian Creole": "Mèsi! Resepsyonis nou an pral kontakte ou byento.",
|
|
};
|
|
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
|
|
|
const reply = apiKey
|
|
? await llmReply(
|
|
`You are a friendly dental office AI assistant. The patient has left their contact information. Thank them in ${lang} and let them know the receptionist will reach out soon. 1-2 sentences, no formatting.`,
|
|
`Patient provided contact info. Acknowledge.`,
|
|
fallback, apiKey, provider, model
|
|
)
|
|
: fallback;
|
|
|
|
return { reply, nextStage: "done" };
|
|
}
|
|
|
|
function transferNode(state: GraphStateType) {
|
|
const lang = state.language || "English";
|
|
return { reply: state.generalFallback || 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_insurance_type", askInsuranceTypeNode)
|
|
.addNode("ask_masshealth_check_consent", askMassHealthCheckConsentNode)
|
|
.addNode("ask_masshealth_info", askMassHealthInfoNode)
|
|
.addNode("ask_existing_insurance", askExistingInsuranceNode)
|
|
.addNode("ask_appointment_time", askAppointmentTimeNode)
|
|
.addNode("acknowledge_appointment_time", acknowledgeAppointmentTimeNode)
|
|
.addNode("handle_appointment_preference",handleAppointmentPreferenceNode)
|
|
.addNode("handle_self_pay", handleSelfPayNode)
|
|
.addNode("ask_contact_info", askContactInfoNode)
|
|
.addNode("acknowledge_contact_info", acknowledgeContactInfoNode)
|
|
.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_insurance_type: "ask_insurance_type",
|
|
ask_masshealth_check_consent: "ask_masshealth_check_consent",
|
|
ask_masshealth_info: "ask_masshealth_info",
|
|
ask_existing_insurance: "ask_existing_insurance",
|
|
ask_appointment_time: "ask_appointment_time",
|
|
acknowledge_appointment_time: "acknowledge_appointment_time",
|
|
handle_appointment_preference: "handle_appointment_preference",
|
|
handle_self_pay: "handle_self_pay",
|
|
ask_contact_info: "ask_contact_info",
|
|
acknowledge_contact_info: "acknowledge_contact_info",
|
|
transfer: "transfer",
|
|
})
|
|
.addEdge("ask_new_or_existing", END)
|
|
.addEdge("ask_new_patient_insurance", END)
|
|
.addEdge("ask_insurance_type", END)
|
|
.addEdge("ask_masshealth_check_consent", END)
|
|
.addEdge("ask_masshealth_info", END)
|
|
.addEdge("ask_existing_insurance", END)
|
|
.addEdge("ask_appointment_time", END)
|
|
.addEdge("acknowledge_appointment_time", END)
|
|
.addEdge("handle_appointment_preference", END)
|
|
.addEdge("handle_self_pay", END)
|
|
.addEdge("ask_contact_info", END)
|
|
.addEdge("acknowledge_contact_info", END)
|
|
.addEdge("transfer", END)
|
|
.compile();
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
export async function runNewPatientStep(
|
|
message: string,
|
|
stage: ConversationStage,
|
|
language: string,
|
|
apiKey: string,
|
|
generalFallback = "",
|
|
provider: AiProvider = "google",
|
|
model?: string
|
|
): Promise<{ reply: string; nextStage: ConversationStage }> {
|
|
const result = await graph.invoke(
|
|
{ message, stage, intent: "", reply: "", language, nextStage: "", generalFallback },
|
|
{ configurable: { apiKey, provider, model } }
|
|
);
|
|
return {
|
|
reply: result.reply || transferMsg(language),
|
|
nextStage: (result.nextStage as ConversationStage) || "done",
|
|
};
|
|
}
|