feat: unify AI voice calls with SMS conversation rules, add Call Templates
AI phone calls now route through the same routePatientTurn() rule engine as SMS (office-hours checks, slot-conflict checks, MassHealth eligibility, staff escalation) instead of a freeform LLM chat, and speak in the patient's preferred language (English/Spanish). Adds a separate "Call Templates" settings section so spoken wording can be customized independently from SMS wording. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
640
apps/Backend/src/ai/patient-conversation-router.ts
Normal file
640
apps/Backend/src/ai/patient-conversation-router.ts
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
import twilio from "twilio";
|
||||||
|
import { prisma as db } from "@repo/db/client";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
import { runReminderGraph } from "./reminder-graph";
|
||||||
|
import { runNewPatientStep } from "./new-patient-graph";
|
||||||
|
import {
|
||||||
|
runRescheduleStep,
|
||||||
|
parseDateOnlyFromMessage,
|
||||||
|
parseTime,
|
||||||
|
isOfficeDayOpen,
|
||||||
|
isWithinOfficeHours,
|
||||||
|
getOfficeHoursDisplay,
|
||||||
|
timeLabel,
|
||||||
|
} from "./reschedule-graph";
|
||||||
|
import { getLlm, type AiProvider } from "./llm-factory";
|
||||||
|
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
||||||
|
import {
|
||||||
|
getPendingReschedule, setPendingReschedule, clearPendingReschedule,
|
||||||
|
setStage, type ConversationStage,
|
||||||
|
} from "./aiHandoffStore";
|
||||||
|
|
||||||
|
// Shared, channel-agnostic implementation of the patient conversation rules
|
||||||
|
// (reminder / reschedule / new-patient graphs, plus the inline MassHealth and
|
||||||
|
// appointment-booking stages) used by both the SMS webhook and the AI voice
|
||||||
|
// call webhook, so a patient gets identical guardrails and outcomes
|
||||||
|
// regardless of channel.
|
||||||
|
|
||||||
|
export interface RoutedPatient {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
phone: string | null;
|
||||||
|
preferredLanguage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteTurnResult {
|
||||||
|
segments: string[];
|
||||||
|
nextStage: ConversationStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the patient's next scheduled appointment as a human-readable string. */
|
||||||
|
export 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Substitute {officeName} in a template string. */
|
||||||
|
export function applyOfficeName(template: string, name: string): string {
|
||||||
|
return template.replace(/\{officeName\}/g, name || "our dental office");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save an outbound SMS message (always sms channel — used for async follow-ups). */
|
||||||
|
async function saveOutboundSms(patientId: number, body: string): Promise<void> {
|
||||||
|
await storage.createCommunication({
|
||||||
|
patientId, channel: "sms", direction: "outbound", status: "sent", body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize a DOB string to zero-padded MM/DD/YYYY required by MassHealth. */
|
||||||
|
function normalizeDob(raw: string): string {
|
||||||
|
const parts = raw.split(/[\/\-\.]/);
|
||||||
|
if (parts.length !== 3) return raw;
|
||||||
|
const [m, d, y] = parts;
|
||||||
|
const mm = String(parseInt(m!, 10)).padStart(2, "0");
|
||||||
|
const dd = String(parseInt(d!, 10)).padStart(2, "0");
|
||||||
|
const yyyy = y!.length === 2 ? `20${y}` : y!;
|
||||||
|
return `${mm}/${dd}/${yyyy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract MassHealth Member ID and date of birth from free text.
|
||||||
|
* Tries regex first, falls back to LLM extraction.
|
||||||
|
*/
|
||||||
|
async function parseMassHealthInfo(
|
||||||
|
message: string,
|
||||||
|
apiKey: string,
|
||||||
|
provider: AiProvider = "google",
|
||||||
|
model?: string
|
||||||
|
): Promise<{ memberId: string | null; dob: string | null }> {
|
||||||
|
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: normalizeDob(`${m}/${d}/${year}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const llm = getLlm(provider, apiKey, model);
|
||||||
|
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);
|
||||||
|
const dob = json.dob ? normalizeDob(String(json.dob)) : null;
|
||||||
|
return { memberId: json.memberId ?? null, dob };
|
||||||
|
} catch {
|
||||||
|
return { memberId: null, dob: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run MassHealth eligibility check in the background (after replying to the
|
||||||
|
* patient) and send the result as a follow-up SMS, regardless of which
|
||||||
|
* channel (SMS or voice call) triggered the check.
|
||||||
|
*/
|
||||||
|
async function runMassHealthCheckAndNotify(
|
||||||
|
patient: RoutedPatient,
|
||||||
|
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;
|
||||||
|
|
||||||
|
await runEligibilityProcessor({
|
||||||
|
userId: patient.userId,
|
||||||
|
insuranceId: memberId,
|
||||||
|
formDob: dob,
|
||||||
|
enrichedPayload: {
|
||||||
|
memberId,
|
||||||
|
dateOfBirth: dob,
|
||||||
|
insuranceSiteKey: "MH",
|
||||||
|
massdhpUsername: credentials.username,
|
||||||
|
massdhpPassword: credentials.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
const activeMessages: Record<string, string> = {
|
||||||
|
English: "Great news! Your MassHealth coverage is active. We can schedule an appointment for you! What date and time would you prefer?",
|
||||||
|
Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¡Podemos programar una cita para usted! ¿Qué fecha y hora prefiere?",
|
||||||
|
Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Podemos agendar uma consulta para você! Qual data e horário prefere?",
|
||||||
|
Mandarin: "好消息!您的MassHealth保险有效。我们可以为您安排预约!您希望什么日期和时间?",
|
||||||
|
Cantonese: "好消息!您的MassHealth保險有效。我們可以為您安排預約!您希望什麼日期和時間?",
|
||||||
|
Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. يمكننا تحديد موعد لك! ما التاريخ والوقت المفضل لديك؟",
|
||||||
|
"Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Nou ka planifye yon randevou pou ou! Ki dat ak lè ou prefere?",
|
||||||
|
};
|
||||||
|
|
||||||
|
const inactiveMessagesNew: Record<string, string> = {
|
||||||
|
English: "Unfortunately, your MassHealth coverage appears to be inactive. Do you have any other insurance?",
|
||||||
|
Spanish: "Lamentablemente, su cobertura de MassHealth parece estar inactiva. ¿Tiene algún otro seguro?",
|
||||||
|
Portuguese: "Infelizmente, sua cobertura MassHealth parece estar inativa. Você tem algum outro plano de saúde?",
|
||||||
|
Mandarin: "很遗憾,您的MassHealth保险似乎无效。您还有其他保险吗?",
|
||||||
|
Cantonese: "很遺憾,您的MassHealth保險似乎無效。您還有其他保險嗎?",
|
||||||
|
Arabic: "للأسف، تغطيتك من MassHealth تبدو غير نشطة. هل لديك أي تأمين آخر؟",
|
||||||
|
"Haitian Creole": "Malerezman, kouvèti MassHealth ou parèt inaktif. Èske ou gen yon lòt asirans?",
|
||||||
|
};
|
||||||
|
|
||||||
|
const inactiveMessagesExisting: 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"]!)
|
||||||
|
: isExistingPatient
|
||||||
|
? (inactiveMessagesExisting[lang] ?? inactiveMessagesExisting["English"]!)
|
||||||
|
: (inactiveMessagesNew[lang] ?? inactiveMessagesNew["English"]!);
|
||||||
|
|
||||||
|
const nextStage: ConversationStage = active
|
||||||
|
? "asked_appointment_time"
|
||||||
|
: isExistingPatient
|
||||||
|
? "asked_self_pay"
|
||||||
|
: "asked_other_insurance_after_inactive";
|
||||||
|
|
||||||
|
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
|
||||||
|
await client.messages.create({
|
||||||
|
body: resultText,
|
||||||
|
from: twilioSettings.phoneNumber,
|
||||||
|
to: patient.phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveOutboundSms(patient.id, resultText);
|
||||||
|
await setStage(patient.userId, patient.id, nextStage);
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
// Silent — don't crash the caller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish(
|
||||||
|
userId: number, patientId: number,
|
||||||
|
segments: string[], nextStage: ConversationStage
|
||||||
|
): Promise<RouteTurnResult> {
|
||||||
|
await setStage(userId, patientId, nextStage);
|
||||||
|
return { segments, nextStage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process one patient message (SMS body or a transcribed spoken turn) against
|
||||||
|
* the current ConversationStage and return what to say next plus the stage
|
||||||
|
* to advance to. Persists the resulting stage itself. Does NOT log the
|
||||||
|
* Communication row for the reply — callers do that per-channel.
|
||||||
|
*/
|
||||||
|
export async function routePatientTurn(params: {
|
||||||
|
patient: RoutedPatient;
|
||||||
|
stage: ConversationStage;
|
||||||
|
message: string;
|
||||||
|
activeAi: { provider: AiProvider; key: string; model: string };
|
||||||
|
chatTemplates: Record<string, string>;
|
||||||
|
officeName: string;
|
||||||
|
}): Promise<RouteTurnResult> {
|
||||||
|
const { patient, stage, message, activeAi, chatTemplates, officeName } = params;
|
||||||
|
const language = patient.preferredLanguage || "English";
|
||||||
|
|
||||||
|
// ── Stage: reminder_initial → self-intro + classify yes/no/other ────────
|
||||||
|
if (stage === "reminder_initial") {
|
||||||
|
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.`;
|
||||||
|
const introText = applyOfficeName(rawGreeting, officeName);
|
||||||
|
|
||||||
|
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||||
|
const { reply: intentReply, intent } = await runReminderGraph(
|
||||||
|
message, activeAi.key, language, apptDatetime,
|
||||||
|
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intentReply) {
|
||||||
|
let nextStage: ConversationStage;
|
||||||
|
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
||||||
|
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
||||||
|
else nextStage = "done";
|
||||||
|
|
||||||
|
if (intent === "no") {
|
||||||
|
const hasDateInMessage =
|
||||||
|
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(message) ||
|
||||||
|
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(message);
|
||||||
|
if (hasDateInMessage) {
|
||||||
|
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
|
||||||
|
message, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
||||||
|
);
|
||||||
|
return finish(patient.userId, patient.id, [introText, rescheduleReply], rescheduleNextStage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finish(patient.userId, patient.id, [introText, intentReply], nextStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No clear intent detected — send only the intro and wait for next reply
|
||||||
|
return finish(patient.userId, patient.id, [introText], "greeted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: greeted → classify yes/no for appointment reminder ──────────
|
||||||
|
if (stage === "greeted") {
|
||||||
|
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||||
|
const { reply: aiReply, intent } = await runReminderGraph(
|
||||||
|
message, activeAi.key, language, apptDatetime,
|
||||||
|
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
||||||
|
);
|
||||||
|
if (aiReply) {
|
||||||
|
let nextStage: ConversationStage;
|
||||||
|
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
||||||
|
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
||||||
|
else nextStage = "done";
|
||||||
|
|
||||||
|
if (intent === "no") {
|
||||||
|
const hasDateInMessage =
|
||||||
|
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(message) ||
|
||||||
|
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(message);
|
||||||
|
if (hasDateInMessage) {
|
||||||
|
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
|
||||||
|
message, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
||||||
|
);
|
||||||
|
return finish(patient.userId, patient.id, [rescheduleReply], rescheduleNextStage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finish(patient.userId, patient.id, [aiReply], nextStage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rescheduling flow stages ────────────────────────────────────────────
|
||||||
|
const rescheduleStages: ConversationStage[] = [
|
||||||
|
"asked_reschedule_confirm", "asked_reschedule_preference",
|
||||||
|
"asked_reschedule_asap", "asked_reschedule_next_week",
|
||||||
|
"asked_reschedule_time", "asked_reschedule_datetime",
|
||||||
|
"asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime",
|
||||||
|
];
|
||||||
|
if (rescheduleStages.includes(stage)) {
|
||||||
|
const { reply: aiReply, nextStage } = await runRescheduleStep(
|
||||||
|
message, stage, language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
||||||
|
);
|
||||||
|
return finish(patient.userId, patient.id, [aiReply], nextStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: awaiting MassHealth member ID + DOB ─────────────────────────
|
||||||
|
if (stage === "awaiting_masshealth_info") {
|
||||||
|
const { memberId, dob } = await parseMassHealthInfo(message, activeAi.key, activeAi.provider, activeAi.model);
|
||||||
|
|
||||||
|
if (!memberId || !dob) {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [retryMessages[language] ?? retryMessages["English"]!], "awaiting_masshealth_info");
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]!;
|
||||||
|
|
||||||
|
runMassHealthCheckAndNotify(patient, memberId, dob, activeAi.key).catch(() => {});
|
||||||
|
return finish(patient.userId, patient.id, [checkingMsg], "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: existing patient said YES to same insurance ──────────────────
|
||||||
|
if (stage === "asked_existing_insurance") {
|
||||||
|
const saysYes = /yes|same|still have|haven't changed|no change|yep|yeah|sí|si|sim|好的|نعم|wi/i.test(message);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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"]!;
|
||||||
|
|
||||||
|
runMassHealthCheckAndNotify(patient, patientRecord.insuranceId, dobStr, activeAi.key, true).catch(() => {});
|
||||||
|
return finish(patient.userId, patient.id, [checkingMsg], "done");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not MassHealth or said NO — fall through to normal graph handling
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: asked_new_or_reschedule ───────────────────────────────────────
|
||||||
|
if (stage === "asked_new_or_reschedule") {
|
||||||
|
const isReschedule = /reschedule|rescheduler|change|modify|move|different|reprogramar|reagendar|cambiar|mudar|\b2\b/i.test(message);
|
||||||
|
const isNew = /new.*appoint|make.*appoint|book.*appoint|new patient|first.*time|nueva cita|nova consulta|\b1\b/i.test(message);
|
||||||
|
|
||||||
|
if (isReschedule) {
|
||||||
|
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||||
|
if (apptDatetime) {
|
||||||
|
const foundMsgs: Record<string, string> = {
|
||||||
|
English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`,
|
||||||
|
Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`,
|
||||||
|
Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`,
|
||||||
|
Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`,
|
||||||
|
Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`,
|
||||||
|
Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`,
|
||||||
|
"Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`,
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [foundMsgs[language] ?? foundMsgs["English"]!], "asked_reschedule_datetime");
|
||||||
|
} else {
|
||||||
|
const notFoundMsgs: Record<string, string> = {
|
||||||
|
English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.",
|
||||||
|
Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.",
|
||||||
|
Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.",
|
||||||
|
Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。",
|
||||||
|
Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。",
|
||||||
|
Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.",
|
||||||
|
"Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.",
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [notFoundMsgs[language] ?? notFoundMsgs["English"]!], "done");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void isNew; // ambiguous or explicit "new" both fall through to the same prompt
|
||||||
|
const newApptMsgs: Record<string, string> = {
|
||||||
|
English: "No problem! To get started, are you an existing patient or a new patient?",
|
||||||
|
Spanish: "¡Sin problema! Para comenzar, ¿es usted un paciente existente o un paciente nuevo?",
|
||||||
|
Portuguese: "Sem problema! Para começar, você é um paciente existente ou um novo paciente?",
|
||||||
|
Mandarin: "没问题!请问您是现有患者还是新患者?",
|
||||||
|
Cantonese: "沒問題!請問您是現有病人還是新病人?",
|
||||||
|
Arabic: "لا مشكلة! للبدء، هل أنت مريض حالي أم مريض جديد؟",
|
||||||
|
"Haitian Creole": "Pa gen pwoblèm! Pou kòmanse, èske ou se yon pasyan egzistan oswa yon nouvo pasyan?",
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [newApptMsgs[language] ?? newApptMsgs["English"]!], "asked_new_or_existing");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: asked_appointment_time → parse date, check office hours ─────
|
||||||
|
if (stage === "asked_appointment_time") {
|
||||||
|
const parsedDate = await parseDateOnlyFromMessage(message, activeAi.key, activeAi.provider, activeAi.model);
|
||||||
|
if (!parsedDate) {
|
||||||
|
const msgs: Record<string, string> = {
|
||||||
|
English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.",
|
||||||
|
Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.",
|
||||||
|
Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.",
|
||||||
|
Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。",
|
||||||
|
Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。",
|
||||||
|
Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.",
|
||||||
|
"Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.",
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_appointment_time");
|
||||||
|
}
|
||||||
|
const { date, dateLabel } = parsedDate;
|
||||||
|
const dayCheck = await isOfficeDayOpen(date, patient.userId);
|
||||||
|
if (!dayCheck.open) {
|
||||||
|
const msgs: Record<string, string> = {
|
||||||
|
English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`,
|
||||||
|
Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
|
||||||
|
Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`,
|
||||||
|
Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
|
||||||
|
Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
|
||||||
|
Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
|
||||||
|
"Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_appointment_time");
|
||||||
|
}
|
||||||
|
setPendingReschedule(patient.userId, patient.id, { newDate: date, dayLabel: dateLabel });
|
||||||
|
const askTimeMsgs: Record<string, string> = {
|
||||||
|
English: `What time do you prefer on ${dateLabel}?`,
|
||||||
|
Spanish: `¿A qué hora prefiere el ${dateLabel}?`,
|
||||||
|
Portuguese: `Que horário você prefere em ${dateLabel}?`,
|
||||||
|
Mandarin: `您希望在 ${dateLabel} 几点?`,
|
||||||
|
Cantonese: `您希望在 ${dateLabel} 幾點?`,
|
||||||
|
Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`,
|
||||||
|
"Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`,
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [askTimeMsgs[language] ?? askTimeMsgs["English"]!], "asked_new_appt_time_for_date");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: asked_new_appt_time_for_date → parse time, check hours ──────
|
||||||
|
if (stage === "asked_new_appt_time_for_date") {
|
||||||
|
const pending = getPendingReschedule(patient.userId, patient.id);
|
||||||
|
if (!pending) {
|
||||||
|
return finish(patient.userId, patient.id, ["I lost track of the date. What day would you prefer?"], "asked_appointment_time");
|
||||||
|
}
|
||||||
|
const startTime = await parseTime(message, activeAi.key, activeAi.provider, activeAi.model);
|
||||||
|
if (!startTime) {
|
||||||
|
const msgs: Record<string, string> = {
|
||||||
|
English: `I didn't catch the time. What time do you prefer on ${pending.dayLabel}? For example: '10am' or '2pm'.`,
|
||||||
|
Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am' o '2pm'.`,
|
||||||
|
Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h' ou '14h'.`,
|
||||||
|
Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点?例如:上午10点或下午2点。`,
|
||||||
|
Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點?例如:上午10點或下午2點。`,
|
||||||
|
Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`,
|
||||||
|
"Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am oswa 2pm.`,
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_new_appt_time_for_date");
|
||||||
|
}
|
||||||
|
const withinHours = await isWithinOfficeHours(pending.newDate, startTime, patient.userId);
|
||||||
|
if (!withinHours) {
|
||||||
|
const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, patient.userId);
|
||||||
|
const timeLbl = timeLabel(startTime);
|
||||||
|
const msgs: Record<string, string> = {
|
||||||
|
English: hoursDisplay
|
||||||
|
? `Our office is not available at ${timeLbl} on ${pending.dayLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?`
|
||||||
|
: `Our office is not available at ${timeLbl} on ${pending.dayLabel}. What other time do you prefer?`,
|
||||||
|
Spanish: hoursDisplay
|
||||||
|
? `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
|
||||||
|
: `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. ¿Qué otro horario prefiere?`,
|
||||||
|
Portuguese: hoursDisplay
|
||||||
|
? `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
|
||||||
|
: `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Que outro horário você prefere?`,
|
||||||
|
Mandarin: hoursDisplay
|
||||||
|
? `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?`
|
||||||
|
: `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。您希望改什么时间?`,
|
||||||
|
Cantonese: hoursDisplay
|
||||||
|
? `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?`
|
||||||
|
: `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。您希望改什麼時間?`,
|
||||||
|
Arabic: hoursDisplay
|
||||||
|
? `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
|
||||||
|
: `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ما وقت آخر تفضله؟`,
|
||||||
|
"Haitian Creole": hoursDisplay
|
||||||
|
? `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
|
||||||
|
: `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Ki lòt lè ou prefere?`,
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_new_appt_time_for_date");
|
||||||
|
}
|
||||||
|
clearPendingReschedule(patient.userId, patient.id);
|
||||||
|
const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`;
|
||||||
|
|
||||||
|
const [nh, nm] = startTime.split(":").map(Number);
|
||||||
|
const endTotal = nh! * 60 + nm! + 60;
|
||||||
|
const endTime = `${String(Math.floor(endTotal / 60)).padStart(2, "0")}:${String(endTotal % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const [patientRecord, firstStaff] = await Promise.all([
|
||||||
|
db.patient.findUnique({
|
||||||
|
where: { id: patient.id },
|
||||||
|
select: { firstName: true, lastName: true },
|
||||||
|
}),
|
||||||
|
db.staff.findFirst({
|
||||||
|
where: { userId: patient.userId },
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (firstStaff) {
|
||||||
|
try {
|
||||||
|
await storage.createAppointment({
|
||||||
|
patientId: patient.id,
|
||||||
|
userId: patient.userId,
|
||||||
|
staffId: firstStaff.id,
|
||||||
|
title: `AI Scheduled - ${(patientRecord?.firstName ?? "") + " " + (patientRecord?.lastName ?? "")}`.trim(),
|
||||||
|
date: pending.newDate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
type: "checkup",
|
||||||
|
status: "scheduled",
|
||||||
|
movedByAi: true,
|
||||||
|
} as any);
|
||||||
|
} catch { /* silent — message still sent, staff can create manually */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMsgs: Record<string, string> = {
|
||||||
|
English: `Thank you! Your preferred appointment at ${apptLabel} was scheduled. Our receptionist will confirm it with you shortly.`,
|
||||||
|
Spanish: `¡Gracias! Su cita preferida el ${apptLabel} fue programada. Nuestra recepcionista lo confirmará con usted en breve.`,
|
||||||
|
Portuguese: `Obrigado! Sua consulta preferida em ${apptLabel} foi agendada. Nossa recepcionista confirmará com você em breve.`,
|
||||||
|
Mandarin: `谢谢!您在 ${apptLabel} 的预约已安排。我们的前台将很快与您确认。`,
|
||||||
|
Cantonese: `多謝!您在 ${apptLabel} 的預約已安排。我們的接待員將很快與您確認。`,
|
||||||
|
Arabic: `شكراً! تم جدولة موعدك في ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيده.`,
|
||||||
|
"Haitian Creole": `Mèsi! Randevou ou a nan ${apptLabel} pwograme. Resepsyonis nou an pral konfime li ak ou byento.`,
|
||||||
|
};
|
||||||
|
return finish(patient.userId, patient.id, [confirmMsgs[language] ?? confirmMsgs["English"]!], "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: new_patient_greeted + multi-step new patient stages ──────────
|
||||||
|
const newPatientStages: ConversationStage[] = [
|
||||||
|
"new_patient_greeted", "asked_new_or_existing",
|
||||||
|
"asked_new_patient_insurance", "asked_insurance_type",
|
||||||
|
"asked_masshealth_check_consent", "asked_existing_insurance",
|
||||||
|
"asked_appointment_preference",
|
||||||
|
"asked_self_pay", "asked_other_insurance_after_inactive",
|
||||||
|
"collecting_contact_info",
|
||||||
|
];
|
||||||
|
if (newPatientStages.includes(stage)) {
|
||||||
|
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
||||||
|
message, stage, language, activeAi.key, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
||||||
|
);
|
||||||
|
return finish(patient.userId, patient.id, [aiReply], nextStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage: done → closing thank-you reply ────────────────────────────────
|
||||||
|
if (stage === "done") {
|
||||||
|
const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(message);
|
||||||
|
if (isThanks) {
|
||||||
|
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||||
|
const CLOSING: Record<string, string> = {
|
||||||
|
English: apptDatetime
|
||||||
|
? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.`
|
||||||
|
: `Thank you for choosing our office! We look forward to seeing you soon.`,
|
||||||
|
Spanish: apptDatetime
|
||||||
|
? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.`
|
||||||
|
: `¡Gracias por elegirnos! Le esperamos pronto.`,
|
||||||
|
Portuguese: apptDatetime
|
||||||
|
? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.`
|
||||||
|
: `Obrigado por nos escolher! Aguardamos sua visita em breve.`,
|
||||||
|
Mandarin: apptDatetime
|
||||||
|
? `感谢您选择我们!期待在 ${apptDatetime} 见到您。`
|
||||||
|
: `感谢您选择我们!期待很快见到您。`,
|
||||||
|
Cantonese: apptDatetime
|
||||||
|
? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。`
|
||||||
|
: `感謝您選擇我們!期待很快見到您。`,
|
||||||
|
Arabic: apptDatetime
|
||||||
|
? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.`
|
||||||
|
: `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`,
|
||||||
|
"Haitian Creole": apptDatetime
|
||||||
|
? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.`
|
||||||
|
: `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`,
|
||||||
|
};
|
||||||
|
const fallback = CLOSING[language] ?? CLOSING["English"]!;
|
||||||
|
if (apptDatetime) {
|
||||||
|
try {
|
||||||
|
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
|
||||||
|
const res = await llm.invoke([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `You are a friendly dental office AI assistant. The patient just said "${message}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: message },
|
||||||
|
]);
|
||||||
|
const aiMsg = String(res.content).trim();
|
||||||
|
if (aiMsg) return finish(patient.userId, patient.id, [aiMsg], "done");
|
||||||
|
} catch { /* fall through to fallback */ }
|
||||||
|
}
|
||||||
|
return finish(patient.userId, patient.id, [fallback], "done");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching stage/branch — hold stage steady with no reply.
|
||||||
|
return { segments: [], nextStage: stage };
|
||||||
|
}
|
||||||
@@ -1,65 +1,6 @@
|
|||||||
import { getLlm, type AiProvider } from "./llm-factory";
|
|
||||||
|
|
||||||
export interface VoiceTurn {
|
|
||||||
role: "assistant" | "user";
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoiceAssistantContext {
|
|
||||||
firstName: string;
|
|
||||||
officeName: string;
|
|
||||||
officeAddress: string;
|
|
||||||
officePhone: string;
|
|
||||||
appointmentDatetime: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CLOSING_PATTERNS =
|
const CLOSING_PATTERNS =
|
||||||
/\b(bye|goodbye|good bye|that'?s all|nothing else|no thanks|no thank you|i'?m good|that'?s it|hang up)\b/i;
|
/\b(bye|goodbye|good bye|that'?s all|nothing else|no thanks|no thank you|i'?m good|that'?s it|hang up)\b/i;
|
||||||
|
|
||||||
export function soundsLikeGoodbye(text: string): boolean {
|
export function soundsLikeGoodbye(text: string): boolean {
|
||||||
return CLOSING_PATTERNS.test(text.trim());
|
return CLOSING_PATTERNS.test(text.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate Lisa's next spoken reply given the call transcript so far.
|
|
||||||
* Falls back to a plain closing line if the LLM call fails, so a broken
|
|
||||||
* key never leaves the caller stuck mid-call.
|
|
||||||
*/
|
|
||||||
export async function runVoiceAssistantTurn(
|
|
||||||
history: VoiceTurn[],
|
|
||||||
ctx: VoiceAssistantContext,
|
|
||||||
apiKey: string,
|
|
||||||
provider: AiProvider = "google",
|
|
||||||
model?: string
|
|
||||||
): Promise<string> {
|
|
||||||
const fallback =
|
|
||||||
"I'm sorry, I'm having trouble right now — our staff will follow up with you shortly. Thank you for calling, goodbye.";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const llm = getLlm(provider, apiKey, model);
|
|
||||||
const system = [
|
|
||||||
`You are Lisa, a friendly AI phone assistant for ${ctx.officeName || "a dental office"}.`,
|
|
||||||
`You are speaking live by phone with ${ctx.firstName || "the patient"}.`,
|
|
||||||
ctx.appointmentDatetime
|
|
||||||
? `Their next appointment is on ${ctx.appointmentDatetime}.`
|
|
||||||
: `They have no upcoming appointment on file.`,
|
|
||||||
ctx.officeAddress ? `Office address: ${ctx.officeAddress}.` : "",
|
|
||||||
ctx.officePhone ? `Office phone: ${ctx.officePhone}.` : "",
|
|
||||||
"You can confirm their appointment and answer general questions about the practice.",
|
|
||||||
"If they want to reschedule or need something you can't resolve yourself, tell them a staff member will call or text them to confirm — never claim you rebooked or changed anything yourself.",
|
|
||||||
"This reply will be read aloud by text-to-speech: keep it conversational and SHORT, 1-2 sentences, no formatting, no lists.",
|
|
||||||
"If they indicate they're done (goodbye, that's all, etc.), give a brief warm goodbye.",
|
|
||||||
].filter(Boolean).join(" ");
|
|
||||||
|
|
||||||
const messages = [
|
|
||||||
{ role: "system", content: system },
|
|
||||||
...history.map((t) => ({ role: t.role === "assistant" ? "assistant" : "user", content: t.text })),
|
|
||||||
];
|
|
||||||
|
|
||||||
const res = await llm.invoke(messages as any);
|
|
||||||
const text = String(res.content).trim();
|
|
||||||
return text || fallback;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -214,8 +214,8 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise<any>
|
|||||||
try {
|
try {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||||
const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms, callTemplate } = req.body;
|
const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms } = req.body;
|
||||||
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms, callTemplate });
|
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms });
|
||||||
const updated = await storage.getAiChatTemplates(userId);
|
const updated = await storage.getAiChatTemplates(userId);
|
||||||
return res.status(200).json(updated);
|
return res.status(200).json(updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -223,6 +223,32 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise<any>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/ai/call-templates
|
||||||
|
router.get("/call-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.getAiCallTemplates(userId);
|
||||||
|
return res.status(200).json(templates);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: "Failed to fetch AI call templates", details: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/ai/call-templates
|
||||||
|
router.put("/call-templates", async (req: Request, res: Response): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||||
|
const { greeting, reminderGreeting, newPatientGreeting, generalFallback } = req.body;
|
||||||
|
await storage.saveAiCallTemplates(userId, { greeting, reminderGreeting, newPatientGreeting, generalFallback });
|
||||||
|
const updated = await storage.getAiCallTemplates(userId);
|
||||||
|
return res.status(200).json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: "Failed to save AI call templates", details: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/ai/internal-chat-settings
|
// GET /api/ai/internal-chat-settings
|
||||||
router.get("/internal-chat-settings", async (req: Request, res: Response): Promise<any> => {
|
router.get("/internal-chat-settings", async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import express, { Request, Response } from "express";
|
|||||||
import twilio from "twilio";
|
import twilio from "twilio";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { prisma as db } from "@repo/db/client";
|
import { prisma as db } from "@repo/db/client";
|
||||||
import { runReminderGraph } from "../ai/reminder-graph";
|
|
||||||
import { runNewPatientStep } from "../ai/new-patient-graph";
|
import { runNewPatientStep } from "../ai/new-patient-graph";
|
||||||
import {
|
import {
|
||||||
runRescheduleStep,
|
|
||||||
parseDateOnlyFromMessage,
|
parseDateOnlyFromMessage,
|
||||||
parseTime,
|
parseTime,
|
||||||
isOfficeDayOpen,
|
isOfficeDayOpen,
|
||||||
@@ -14,12 +12,14 @@ import {
|
|||||||
timeLabel,
|
timeLabel,
|
||||||
} from "../ai/reschedule-graph";
|
} from "../ai/reschedule-graph";
|
||||||
import { getLlm, resolveAiProvider } from "../ai/llm-factory";
|
import { getLlm, resolveAiProvider } from "../ai/llm-factory";
|
||||||
import { runVoiceAssistantTurn, soundsLikeGoodbye } from "../ai/voice-assistant";
|
import { soundsLikeGoodbye } from "../ai/voice-assistant";
|
||||||
import { getPublicBaseUrl } from "../utils/publicUrl";
|
import { getPublicBaseUrl } from "../utils/publicUrl";
|
||||||
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
import {
|
||||||
|
routePatientTurn, getAppointmentDatetime, applyOfficeName,
|
||||||
|
} from "../ai/patient-conversation-router";
|
||||||
import {
|
import {
|
||||||
getHandoff, getAfterHoursHandoff,
|
getHandoff, getAfterHoursHandoff,
|
||||||
getStage, setStage,
|
getStage, setStage, resetConversation,
|
||||||
setPendingReschedule, getPendingReschedule, clearPendingReschedule,
|
setPendingReschedule, getPendingReschedule, clearPendingReschedule,
|
||||||
type ConversationStage,
|
type ConversationStage,
|
||||||
} from "../ai/aiHandoffStore";
|
} from "../ai/aiHandoffStore";
|
||||||
@@ -48,21 +48,6 @@ function empty(): string {
|
|||||||
return "<Response></Response>";
|
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. */
|
/** Check if right now is outside office hours for the given user. */
|
||||||
async function isAfterHours(userId: number): Promise<boolean> {
|
async function isAfterHours(userId: number): Promise<boolean> {
|
||||||
const record = await storage.getOfficeHours(userId);
|
const record = await storage.getOfficeHours(userId);
|
||||||
@@ -82,11 +67,6 @@ async function isAfterHours(userId: number): Promise<boolean> {
|
|||||||
return true;
|
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. */
|
/** Save an outbound message and return the text. */
|
||||||
async function saveOutbound(patientId: number, body: string): Promise<void> {
|
async function saveOutbound(patientId: number, body: string): Promise<void> {
|
||||||
await storage.createCommunication({
|
await storage.createCommunication({
|
||||||
@@ -94,160 +74,6 @@ async function saveOutbound(patientId: number, body: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract MassHealth Member ID and date of birth from a free-text SMS.
|
|
||||||
* Tries regex first, falls back to LLM extraction.
|
|
||||||
*/
|
|
||||||
/** Normalize a DOB string to zero-padded MM/DD/YYYY required by MassHealth. */
|
|
||||||
function normalizeDob(raw: string): string {
|
|
||||||
const parts = raw.split(/[\/\-\.]/);
|
|
||||||
if (parts.length !== 3) return raw;
|
|
||||||
const [m, d, y] = parts;
|
|
||||||
const mm = String(parseInt(m!, 10)).padStart(2, "0");
|
|
||||||
const dd = String(parseInt(d!, 10)).padStart(2, "0");
|
|
||||||
const yyyy = y!.length === 2 ? `20${y}` : y!;
|
|
||||||
return `${mm}/${dd}/${yyyy}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseMassHealthInfo(
|
|
||||||
message: string,
|
|
||||||
apiKey: string,
|
|
||||||
provider: import("../ai/llm-factory").AiProvider = "google",
|
|
||||||
model?: 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: normalizeDob(`${m}/${d}/${year}`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to LLM structured extraction
|
|
||||||
try {
|
|
||||||
const llm = getLlm(provider, apiKey, model);
|
|
||||||
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);
|
|
||||||
const dob = json.dob ? normalizeDob(String(json.dob)) : null;
|
|
||||||
return { memberId: json.memberId ?? null, dob };
|
|
||||||
} 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 ────────────────────────────────────────────────────────────────
|
|
||||||
const activeMessages: Record<string, string> = {
|
|
||||||
English: "Great news! Your MassHealth coverage is active. We can schedule an appointment for you! What date and time would you prefer?",
|
|
||||||
Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¡Podemos programar una cita para usted! ¿Qué fecha y hora prefiere?",
|
|
||||||
Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Podemos agendar uma consulta para você! Qual data e horário prefere?",
|
|
||||||
Mandarin: "好消息!您的MassHealth保险有效。我们可以为您安排预约!您希望什么日期和时间?",
|
|
||||||
Cantonese: "好消息!您的MassHealth保險有效。我們可以為您安排預約!您希望什麼日期和時間?",
|
|
||||||
Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. يمكننا تحديد موعد لك! ما التاريخ والوقت المفضل لديك؟",
|
|
||||||
"Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Nou ka planifye yon randevou pou ou! Ki dat ak lè ou prefere?",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── INACTIVE: new patient → ask other insurance; existing → ask self-pay ──
|
|
||||||
const inactiveMessagesNew: Record<string, string> = {
|
|
||||||
English: "Unfortunately, your MassHealth coverage appears to be inactive. Do you have any other insurance?",
|
|
||||||
Spanish: "Lamentablemente, su cobertura de MassHealth parece estar inactiva. ¿Tiene algún otro seguro?",
|
|
||||||
Portuguese: "Infelizmente, sua cobertura MassHealth parece estar inativa. Você tem algum outro plano de saúde?",
|
|
||||||
Mandarin: "很遗憾,您的MassHealth保险似乎无效。您还有其他保险吗?",
|
|
||||||
Cantonese: "很遺憾,您的MassHealth保險似乎無效。您還有其他保險嗎?",
|
|
||||||
Arabic: "للأسف، تغطيتك من MassHealth تبدو غير نشطة. هل لديك أي تأمين آخر؟",
|
|
||||||
"Haitian Creole": "Malerezman, kouvèti MassHealth ou parèt inaktif. Èske ou gen yon lòt asirans?",
|
|
||||||
};
|
|
||||||
|
|
||||||
const inactiveMessagesExisting: 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"]!)
|
|
||||||
: isExistingPatient
|
|
||||||
? (inactiveMessagesExisting[lang] ?? inactiveMessagesExisting["English"]!)
|
|
||||||
: (inactiveMessagesNew[lang] ?? inactiveMessagesNew["English"]!);
|
|
||||||
|
|
||||||
const nextStage: ConversationStage = active
|
|
||||||
? "asked_appointment_time"
|
|
||||||
: isExistingPatient
|
|
||||||
? "asked_self_pay"
|
|
||||||
: "asked_other_insurance_after_inactive";
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
await setStage(patient.userId, patient.id, nextStage);
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
// Silent — don't crash the main request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Empathetic one-liner (instant keyword-based, no API latency) ─────────────
|
// ── Empathetic one-liner (instant keyword-based, no API latency) ─────────────
|
||||||
|
|
||||||
function getEmpatheticAck(message: string, language: string): string {
|
function getEmpatheticAck(message: string, language: string): string {
|
||||||
@@ -615,451 +441,31 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
|||||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||||
|
|
||||||
// ── Helper: send reply + set stage ─────────────────────────────────────
|
// ── Shared business rules (reminder/reschedule/new-patient graphs, plus the
|
||||||
const reply = async (text: string, nextStage: ConversationStage) => {
|
// MassHealth and appointment-booking stages) — the exact same code path the
|
||||||
await saveOutbound(patient.id, text);
|
// AI voice call webhook uses, so a patient gets identical guardrails and
|
||||||
await setStage(patient.userId, patient.id, nextStage);
|
// outcomes regardless of channel.
|
||||||
res.set("Content-Type", "text/xml");
|
const routed = await routePatientTurn({
|
||||||
return res.send(twimlReply(text));
|
patient, stage, message: Body, activeAi, chatTemplates, officeName,
|
||||||
};
|
});
|
||||||
|
|
||||||
// ── Stage: reminder_initial → two messages: 1) AI intro, 2) intent response ──
|
if (routed.segments.length > 0) {
|
||||||
if (stage === "reminder_initial") {
|
for (const seg of routed.segments) await saveOutbound(patient.id, seg);
|
||||||
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.`;
|
|
||||||
const introText = applyOfficeName(rawGreeting, officeName);
|
|
||||||
|
|
||||||
// Use Google AI (LangGraph) to read the patient's reply and classify yes/no
|
// reminder_initial's self-intro (segments[0]) is queued via REST so it's
|
||||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
// guaranteed to arrive before the classified reply that follows it.
|
||||||
const { reply: intentReply, intent } = await runReminderGraph(
|
if (stage === "reminder_initial" && routed.segments.length > 1) {
|
||||||
Body, activeAi.key, language, apptDatetime,
|
|
||||||
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
|
||||||
);
|
|
||||||
|
|
||||||
if (intentReply) {
|
|
||||||
let nextStage: ConversationStage;
|
|
||||||
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
|
||||||
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
|
||||||
else nextStage = "done";
|
|
||||||
|
|
||||||
// Send message 1 (AI intro) via REST API — queued FIRST in Twilio so it arrives first
|
|
||||||
const twilioSettings = await storage.getTwilioSettings(patient.userId);
|
const twilioSettings = await storage.getTwilioSettings(patient.userId);
|
||||||
if (twilioSettings) {
|
if (twilioSettings) {
|
||||||
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
|
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
|
||||||
await client.messages.create({ body: introText, from: twilioSettings.phoneNumber, to: From });
|
await client.messages.create({ body: routed.segments[0]!, from: twilioSettings.phoneNumber, to: From });
|
||||||
await saveOutbound(patient.id, introText);
|
|
||||||
}
|
}
|
||||||
|
res.set("Content-Type", "text/xml");
|
||||||
// If patient said "no" but already included a date (e.g. "no, 5/18"),
|
return res.send(twimlMessages(...routed.segments.slice(1)));
|
||||||
// skip "when to reschedule?" and go straight to date processing
|
|
||||||
if (intent === "no") {
|
|
||||||
const hasDateInMessage =
|
|
||||||
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) ||
|
|
||||||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body);
|
|
||||||
if (hasDateInMessage) {
|
|
||||||
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
|
|
||||||
Body, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
|
||||||
);
|
|
||||||
return reply(rescheduleReply, rescheduleNextStage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send message 2 (yes/no response) via TwiML — queued SECOND
|
|
||||||
return reply(intentReply, nextStage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No clear intent detected — send only the intro and wait for next reply
|
|
||||||
return reply(introText, "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, activeAi.key, language, apptDatetime,
|
|
||||||
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
|
||||||
);
|
|
||||||
if (aiReply) {
|
|
||||||
let nextStage: ConversationStage;
|
|
||||||
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
|
||||||
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
|
||||||
else nextStage = "done";
|
|
||||||
|
|
||||||
// If patient said "no" but already included a date, skip straight to date processing
|
|
||||||
if (intent === "no") {
|
|
||||||
const hasDateInMessage =
|
|
||||||
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) ||
|
|
||||||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body);
|
|
||||||
if (hasDateInMessage) {
|
|
||||||
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
|
|
||||||
Body, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
|
||||||
);
|
|
||||||
return reply(rescheduleReply, rescheduleNextStage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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", "asked_reschedule_datetime",
|
|
||||||
"asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime",
|
|
||||||
];
|
|
||||||
if (rescheduleStages.includes(stage)) {
|
|
||||||
const { reply: aiReply, nextStage } = await runRescheduleStep(
|
|
||||||
Body, stage, language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
|
||||||
);
|
|
||||||
return reply(aiReply, nextStage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: awaiting MassHealth member ID + DOB ────────────────────────
|
|
||||||
if (stage === "awaiting_masshealth_info") {
|
|
||||||
const { memberId, dob } = await parseMassHealthInfo(Body, activeAi.key, activeAi.provider, activeAi.model);
|
|
||||||
|
|
||||||
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);
|
|
||||||
await setStage(patient.userId, patient.id, "done");
|
|
||||||
res.set("Content-Type", "text/xml");
|
res.set("Content-Type", "text/xml");
|
||||||
res.send(twimlReply(checkingMsg));
|
return res.send(twimlMessages(...routed.segments));
|
||||||
|
|
||||||
// Fire-and-forget: run check and send result SMS when complete
|
|
||||||
runMassHealthCheckAndNotify(patient, memberId, dob, activeAi.key).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);
|
|
||||||
await setStage(patient.userId, patient.id, "done");
|
|
||||||
res.set("Content-Type", "text/xml");
|
|
||||||
res.send(twimlReply(checkingMsg));
|
|
||||||
|
|
||||||
// Fire-and-forget Selenium check; existing patient gets simpler result
|
|
||||||
runMassHealthCheckAndNotify(
|
|
||||||
patient, patientRecord.insuranceId, dobStr, activeAi.key, true
|
|
||||||
).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Not MassHealth or said NO — fall through to normal graph handling
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: asked_new_or_reschedule ────────────────────────────────────
|
|
||||||
if (stage === "asked_new_or_reschedule") {
|
|
||||||
const isReschedule = /reschedule|rescheduler|change|modify|move|different|reprogramar|reagendar|cambiar|mudar|\b2\b/i.test(Body);
|
|
||||||
const isNew = /new.*appoint|make.*appoint|book.*appoint|new patient|first.*time|nueva cita|nova consulta|\b1\b/i.test(Body);
|
|
||||||
|
|
||||||
if (isReschedule) {
|
|
||||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
|
||||||
if (apptDatetime) {
|
|
||||||
const foundMsgs: Record<string, string> = {
|
|
||||||
English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`,
|
|
||||||
Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`,
|
|
||||||
Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`,
|
|
||||||
Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`,
|
|
||||||
Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`,
|
|
||||||
Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`,
|
|
||||||
"Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`,
|
|
||||||
};
|
|
||||||
return reply(foundMsgs[language] ?? foundMsgs["English"]!, "asked_reschedule_datetime");
|
|
||||||
} else {
|
|
||||||
const notFoundMsgs: Record<string, string> = {
|
|
||||||
English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.",
|
|
||||||
Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.",
|
|
||||||
Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.",
|
|
||||||
Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。",
|
|
||||||
Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。",
|
|
||||||
Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.",
|
|
||||||
"Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.",
|
|
||||||
};
|
|
||||||
return reply(notFoundMsgs[language] ?? notFoundMsgs["English"]!, "done");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "new appointment" or ambiguous → ask new/existing patient
|
|
||||||
const newApptMsgs: Record<string, string> = {
|
|
||||||
English: "No problem! To get started, are you an existing patient or a new patient?",
|
|
||||||
Spanish: "¡Sin problema! Para comenzar, ¿es usted un paciente existente o un paciente nuevo?",
|
|
||||||
Portuguese: "Sem problema! Para começar, você é um paciente existente ou um novo paciente?",
|
|
||||||
Mandarin: "没问题!请问您是现有患者还是新患者?",
|
|
||||||
Cantonese: "沒問題!請問您是現有病人還是新病人?",
|
|
||||||
Arabic: "لا مشكلة! للبدء، هل أنت مريض حالي أم مريض جديد؟",
|
|
||||||
"Haitian Creole": "Pa gen pwoblèm! Pou kòmanse, èske ou se yon pasyan egzistan oswa yon nouvo pasyan?",
|
|
||||||
};
|
|
||||||
return reply(newApptMsgs[language] ?? newApptMsgs["English"]!, "asked_new_or_existing");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: asked_appointment_time → parse date, check office hours ───
|
|
||||||
if (stage === "asked_appointment_time") {
|
|
||||||
const parsedDate = await parseDateOnlyFromMessage(Body, activeAi.key, activeAi.provider, activeAi.model);
|
|
||||||
if (!parsedDate) {
|
|
||||||
const msgs: Record<string, string> = {
|
|
||||||
English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.",
|
|
||||||
Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.",
|
|
||||||
Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.",
|
|
||||||
Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。",
|
|
||||||
Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。",
|
|
||||||
Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.",
|
|
||||||
"Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.",
|
|
||||||
};
|
|
||||||
return reply(msgs[language] ?? msgs["English"]!, "asked_appointment_time");
|
|
||||||
}
|
|
||||||
const { date, dateLabel } = parsedDate;
|
|
||||||
const dayCheck = await isOfficeDayOpen(date, patient.userId);
|
|
||||||
if (!dayCheck.open) {
|
|
||||||
const msgs: Record<string, string> = {
|
|
||||||
English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`,
|
|
||||||
Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
|
|
||||||
Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`,
|
|
||||||
Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
|
|
||||||
Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
|
|
||||||
Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
|
|
||||||
"Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
|
|
||||||
};
|
|
||||||
return reply(msgs[language] ?? msgs["English"]!, "asked_appointment_time");
|
|
||||||
}
|
|
||||||
// Day is open — save pending date and ask for preferred time
|
|
||||||
setPendingReschedule(patient.userId, patient.id, { newDate: date, dayLabel: dateLabel });
|
|
||||||
const askTimeMsgs: Record<string, string> = {
|
|
||||||
English: `What time do you prefer on ${dateLabel}?`,
|
|
||||||
Spanish: `¿A qué hora prefiere el ${dateLabel}?`,
|
|
||||||
Portuguese: `Que horário você prefere em ${dateLabel}?`,
|
|
||||||
Mandarin: `您希望在 ${dateLabel} 几点?`,
|
|
||||||
Cantonese: `您希望在 ${dateLabel} 幾點?`,
|
|
||||||
Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`,
|
|
||||||
"Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`,
|
|
||||||
};
|
|
||||||
return reply(askTimeMsgs[language] ?? askTimeMsgs["English"]!, "asked_new_appt_time_for_date");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: asked_new_appt_time_for_date → parse time, check hours ────
|
|
||||||
if (stage === "asked_new_appt_time_for_date") {
|
|
||||||
const pending = getPendingReschedule(patient.userId, patient.id);
|
|
||||||
if (!pending) {
|
|
||||||
return reply("I lost track of the date. What day would you prefer?", "asked_appointment_time");
|
|
||||||
}
|
|
||||||
const startTime = await parseTime(Body, activeAi.key, activeAi.provider, activeAi.model);
|
|
||||||
if (!startTime) {
|
|
||||||
const msgs: Record<string, string> = {
|
|
||||||
English: `I didn't catch the time. What time do you prefer on ${pending.dayLabel}? For example: '10am' or '2pm'.`,
|
|
||||||
Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am' o '2pm'.`,
|
|
||||||
Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h' ou '14h'.`,
|
|
||||||
Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点?例如:上午10点或下午2点。`,
|
|
||||||
Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點?例如:上午10點或下午2點。`,
|
|
||||||
Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`,
|
|
||||||
"Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am oswa 2pm.`,
|
|
||||||
};
|
|
||||||
return reply(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date");
|
|
||||||
}
|
|
||||||
const withinHours = await isWithinOfficeHours(pending.newDate, startTime, patient.userId);
|
|
||||||
if (!withinHours) {
|
|
||||||
const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, patient.userId);
|
|
||||||
const timeLbl = timeLabel(startTime);
|
|
||||||
const msgs: Record<string, string> = {
|
|
||||||
English: hoursDisplay
|
|
||||||
? `Our office is not available at ${timeLbl} on ${pending.dayLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?`
|
|
||||||
: `Our office is not available at ${timeLbl} on ${pending.dayLabel}. What other time do you prefer?`,
|
|
||||||
Spanish: hoursDisplay
|
|
||||||
? `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
|
|
||||||
: `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. ¿Qué otro horario prefiere?`,
|
|
||||||
Portuguese: hoursDisplay
|
|
||||||
? `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
|
|
||||||
: `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Que outro horário você prefere?`,
|
|
||||||
Mandarin: hoursDisplay
|
|
||||||
? `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?`
|
|
||||||
: `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。您希望改什么时间?`,
|
|
||||||
Cantonese: hoursDisplay
|
|
||||||
? `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?`
|
|
||||||
: `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。您希望改什麼時間?`,
|
|
||||||
Arabic: hoursDisplay
|
|
||||||
? `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
|
|
||||||
: `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ما وقت آخر تفضله؟`,
|
|
||||||
"Haitian Creole": hoursDisplay
|
|
||||||
? `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
|
|
||||||
: `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Ki lòt lè ou prefere?`,
|
|
||||||
};
|
|
||||||
return reply(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date");
|
|
||||||
}
|
|
||||||
clearPendingReschedule(patient.userId, patient.id);
|
|
||||||
const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`;
|
|
||||||
|
|
||||||
// Calculate end time (60-minute default slot)
|
|
||||||
const [nh, nm] = startTime.split(":").map(Number);
|
|
||||||
const endTotal = nh! * 60 + nm! + 60;
|
|
||||||
const endTime = `${String(Math.floor(endTotal / 60)).padStart(2, "0")}:${String(endTotal % 60).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
// Look up patient name + find first staff for this office
|
|
||||||
const [patientRecord, firstStaff] = await Promise.all([
|
|
||||||
db.patient.findUnique({
|
|
||||||
where: { id: patient.id },
|
|
||||||
select: { firstName: true, lastName: true },
|
|
||||||
}),
|
|
||||||
db.staff.findFirst({
|
|
||||||
where: { userId: patient.userId },
|
|
||||||
orderBy: { id: "asc" },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create the appointment if a staff member exists
|
|
||||||
if (firstStaff) {
|
|
||||||
try {
|
|
||||||
await storage.createAppointment({
|
|
||||||
patientId: patient.id,
|
|
||||||
userId: patient.userId,
|
|
||||||
staffId: firstStaff.id,
|
|
||||||
title: `AI Scheduled - ${(patientRecord?.firstName ?? "") + " " + (patientRecord?.lastName ?? "")}`.trim(),
|
|
||||||
date: pending.newDate,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
type: "checkup",
|
|
||||||
status: "scheduled",
|
|
||||||
movedByAi: true,
|
|
||||||
} as any);
|
|
||||||
} catch { /* silent — message still sent, staff can create manually */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmMsgs: Record<string, string> = {
|
|
||||||
English: `Thank you! Your preferred appointment at ${apptLabel} was scheduled. Our receptionist will confirm it with you shortly.`,
|
|
||||||
Spanish: `¡Gracias! Su cita preferida el ${apptLabel} fue programada. Nuestra recepcionista lo confirmará con usted en breve.`,
|
|
||||||
Portuguese: `Obrigado! Sua consulta preferida em ${apptLabel} foi agendada. Nossa recepcionista confirmará com você em breve.`,
|
|
||||||
Mandarin: `谢谢!您在 ${apptLabel} 的预约已安排。我们的前台将很快与您确认。`,
|
|
||||||
Cantonese: `多謝!您在 ${apptLabel} 的預約已安排。我們的接待員將很快與您確認。`,
|
|
||||||
Arabic: `شكراً! تم جدولة موعدك في ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيده.`,
|
|
||||||
"Haitian Creole": `Mèsi! Randevou ou a nan ${apptLabel} pwograme. Resepsyonis nou an pral konfime li ak ou byento.`,
|
|
||||||
};
|
|
||||||
return reply(confirmMsgs[language] ?? confirmMsgs["English"]!, "done");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: new_patient_greeted + multi-step new patient stages ────────
|
|
||||||
const newPatientStages: ConversationStage[] = [
|
|
||||||
"new_patient_greeted", "asked_new_or_existing",
|
|
||||||
"asked_new_patient_insurance", "asked_insurance_type",
|
|
||||||
"asked_masshealth_check_consent", "asked_existing_insurance",
|
|
||||||
"asked_appointment_preference",
|
|
||||||
"asked_self_pay", "asked_other_insurance_after_inactive",
|
|
||||||
"collecting_contact_info",
|
|
||||||
];
|
|
||||||
if (newPatientStages.includes(stage)) {
|
|
||||||
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
|
||||||
Body, stage, language, activeAi.key, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
|
||||||
);
|
|
||||||
return reply(aiReply, nextStage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: done → closing thank-you reply ────────────────────────────
|
|
||||||
// When the patient sends a thank-you / acknowledgement after the conversation
|
|
||||||
// is complete, reply warmly with their upcoming appointment time.
|
|
||||||
if (stage === "done") {
|
|
||||||
const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(Body);
|
|
||||||
if (isThanks) {
|
|
||||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
|
||||||
const CLOSING: Record<string, string> = {
|
|
||||||
English: apptDatetime
|
|
||||||
? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.`
|
|
||||||
: `Thank you for choosing our office! We look forward to seeing you soon.`,
|
|
||||||
Spanish: apptDatetime
|
|
||||||
? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.`
|
|
||||||
: `¡Gracias por elegirnos! Le esperamos pronto.`,
|
|
||||||
Portuguese: apptDatetime
|
|
||||||
? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.`
|
|
||||||
: `Obrigado por nos escolher! Aguardamos sua visita em breve.`,
|
|
||||||
Mandarin: apptDatetime
|
|
||||||
? `感谢您选择我们!期待在 ${apptDatetime} 见到您。`
|
|
||||||
: `感谢您选择我们!期待很快见到您。`,
|
|
||||||
Cantonese: apptDatetime
|
|
||||||
? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。`
|
|
||||||
: `感謝您選擇我們!期待很快見到您。`,
|
|
||||||
Arabic: apptDatetime
|
|
||||||
? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.`
|
|
||||||
: `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`,
|
|
||||||
"Haitian Creole": apptDatetime
|
|
||||||
? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.`
|
|
||||||
: `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`,
|
|
||||||
};
|
|
||||||
const fallback = CLOSING[language] ?? CLOSING["English"]!;
|
|
||||||
if (apptDatetime) {
|
|
||||||
try {
|
|
||||||
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
|
|
||||||
const res = await llm.invoke([
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: `You are a friendly dental office AI assistant. The patient just said "${Body}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`,
|
|
||||||
},
|
|
||||||
{ role: "user", content: Body },
|
|
||||||
]);
|
|
||||||
const aiMsg = String(res.content).trim();
|
|
||||||
if (aiMsg) return reply(aiMsg, "done");
|
|
||||||
} catch { /* fall through to fallback */ }
|
|
||||||
}
|
|
||||||
return reply(fallback, "done");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage: initial / done (patient texts in fresh) ───────────────────
|
// ── Stage: initial / done (patient texts in fresh) ───────────────────
|
||||||
@@ -1237,27 +643,43 @@ const MAX_AI_CALL_TURNS = 12;
|
|||||||
interface AiCallSession {
|
interface AiCallSession {
|
||||||
patientId: number;
|
patientId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
phone: string | null;
|
||||||
|
language: string;
|
||||||
|
officeName: string;
|
||||||
|
chatTemplates: Record<string, string>;
|
||||||
history: { role: "assistant" | "user"; text: string }[];
|
history: { role: "assistant" | "user"; text: string }[];
|
||||||
turns: number;
|
turns: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiCallSessions = new Map<string, AiCallSession>();
|
const aiCallSessions = new Map<string, AiCallSession>();
|
||||||
|
|
||||||
function aiVoiceTwiml(sayText: string, gatherActionUrl: string): string {
|
interface VoiceLocale { gatherLang: string; sayVoice: string; }
|
||||||
|
|
||||||
|
/** English/Spanish only for now — Twilio's native voice/recognition coverage
|
||||||
|
* for the app's other supported languages (Mandarin, Cantonese, Arabic,
|
||||||
|
* Haitian Creole) is inconsistent, so those fall back to English on calls. */
|
||||||
|
function voiceLocaleFor(language: string): VoiceLocale {
|
||||||
|
if (language === "Spanish") return { gatherLang: "es-US", sayVoice: "Polly.Lupe" };
|
||||||
|
return { gatherLang: "en-US", sayVoice: "Polly.Joanna" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" };
|
||||||
|
|
||||||
|
function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string {
|
||||||
// action is always set, so Twilio posts back to it on both speech and
|
// action is always set, so Twilio posts back to it on both speech and
|
||||||
// silence timeout — nothing after </Gather> would ever run.
|
// silence timeout — nothing after </Gather> would ever run.
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Response>
|
<Response>
|
||||||
<Gather input="speech" language="en-US" speechTimeout="auto" action="${gatherActionUrl}" method="POST">
|
<Gather input="speech" language="${locale.gatherLang}" speechTimeout="auto" action="${gatherActionUrl}" method="POST">
|
||||||
<Say voice="alice">${escapeXml(sayText)}</Say>
|
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
|
||||||
</Gather>
|
</Gather>
|
||||||
</Response>`;
|
</Response>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aiVoiceHangup(sayText: string): string {
|
function aiVoiceHangup(sayText: string, locale: VoiceLocale = ENGLISH_LOCALE): string {
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Response>
|
<Response>
|
||||||
<Say voice="alice">${escapeXml(sayText)}</Say>
|
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
|
||||||
<Hangup/>
|
<Hangup/>
|
||||||
</Response>`;
|
</Response>`;
|
||||||
}
|
}
|
||||||
@@ -1266,6 +688,9 @@ function aiVoiceHangup(sayText: string): string {
|
|||||||
// Entry point + conversation loop for outbound AI-driven calls placed via
|
// Entry point + conversation loop for outbound AI-driven calls placed via
|
||||||
// POST /api/twilio/make-ai-call. Twilio hits this once when the call connects
|
// POST /api/twilio/make-ai-call. Twilio hits this once when the call connects
|
||||||
// (no CallSid session yet) and again after every <Gather> with SpeechResult.
|
// (no CallSid session yet) and again after every <Gather> with SpeechResult.
|
||||||
|
// Each spoken turn is routed through the same routePatientTurn() logic the
|
||||||
|
// SMS webhook uses, so calls follow identical rules/guardrails, in the
|
||||||
|
// patient's preferred language (English/Spanish supported for voice).
|
||||||
router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<any> => {
|
router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<any> => {
|
||||||
res.set("Content-Type", "text/xml");
|
res.set("Content-Type", "text/xml");
|
||||||
try {
|
try {
|
||||||
@@ -1279,26 +704,35 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
const patientId = parseInt(patientIdParam || "", 10);
|
const patientId = parseInt(patientIdParam || "", 10);
|
||||||
const patient = !isNaN(patientId)
|
const patient = !isNaN(patientId)
|
||||||
? await db.patient.findUnique({ where: { id: patientId }, select: { id: true, userId: true, firstName: true } })
|
? await db.patient.findUnique({
|
||||||
|
where: { id: patientId },
|
||||||
|
select: { id: true, userId: true, firstName: true, phone: true, preferredLanguage: true },
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye."));
|
if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye."));
|
||||||
|
|
||||||
session = { patientId: patient.id, userId: patient.userId, history: [], turns: 0 };
|
const language = patient.preferredLanguage || "English";
|
||||||
aiCallSessions.set(CallSid, session);
|
const locale = voiceLocaleFor(language);
|
||||||
|
|
||||||
|
// Resume an in-progress cross-channel conversation (e.g. mid-reschedule
|
||||||
|
// from a prior text) if one exists; otherwise start a fresh reminder-style
|
||||||
|
// check-in call, matching the default the reminder-SMS batch send uses.
|
||||||
|
const currentStage = await getStage(patient.userId, patient.id);
|
||||||
|
if (currentStage === "initial" || currentStage === "done") {
|
||||||
|
await resetConversation(patient.userId, patient.id);
|
||||||
|
}
|
||||||
|
|
||||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||||
const officeAddress = [
|
const callTemplates = await storage.getAiCallTemplates(patient.userId);
|
||||||
(officeContact as any)?.streetAddress?.trim(),
|
|
||||||
(officeContact as any)?.city?.trim(),
|
|
||||||
(officeContact as any)?.state?.trim(),
|
|
||||||
(officeContact as any)?.zipCode?.trim(),
|
|
||||||
].filter(Boolean).join(", ");
|
|
||||||
const officePhone = (officeContact as any)?.phoneNumber?.trim() || "";
|
|
||||||
const appointmentDatetime = await getAppointmentDatetime(patient.id);
|
|
||||||
|
|
||||||
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
|
session = {
|
||||||
const rawCallTemplate = chatTemplates.callTemplate?.trim() ||
|
patientId: patient.id, userId: patient.userId, phone: patient.phone,
|
||||||
|
language, officeName, chatTemplates: callTemplates, history: [], turns: 0,
|
||||||
|
};
|
||||||
|
aiCallSessions.set(CallSid, session);
|
||||||
|
|
||||||
|
const rawCallTemplate = callTemplates.greeting?.trim() ||
|
||||||
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?";
|
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?";
|
||||||
const greeting = rawCallTemplate
|
const greeting = rawCallTemplate
|
||||||
.replace(/\{firstName\}/g, patient.firstName || "there")
|
.replace(/\{firstName\}/g, patient.firstName || "there")
|
||||||
@@ -1310,17 +744,16 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
|
|||||||
direction: "outbound", status: "completed", body: greeting, twilioSid: CallSid,
|
direction: "outbound", status: "completed", body: greeting, twilioSid: CallSid,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stash office context on the session for later turns
|
|
||||||
(session as any).ctx = { officeName, officeAddress, officePhone, appointmentDatetime };
|
|
||||||
|
|
||||||
const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${patient.id}`;
|
const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${patient.id}`;
|
||||||
return res.send(aiVoiceTwiml(greeting, actionUrl));
|
return res.send(aiVoiceTwiml(greeting, actionUrl, locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locale = voiceLocaleFor(session.language);
|
||||||
|
|
||||||
// ── Caller stayed silent past the speech timeout ──────────────────────
|
// ── Caller stayed silent past the speech timeout ──────────────────────
|
||||||
if (!SpeechResult?.trim()) {
|
if (!SpeechResult?.trim()) {
|
||||||
aiCallSessions.delete(CallSid);
|
aiCallSessions.delete(CallSid);
|
||||||
return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye."));
|
return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye.", locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
session.history.push({ role: "user", text: SpeechResult.trim() });
|
session.history.push({ role: "user", text: SpeechResult.trim() });
|
||||||
@@ -1334,23 +767,28 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
|
|||||||
// ── Caller said something that sounds like goodbye, or we've hit the cap ──
|
// ── Caller said something that sounds like goodbye, or we've hit the cap ──
|
||||||
if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) {
|
if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) {
|
||||||
aiCallSessions.delete(CallSid);
|
aiCallSessions.delete(CallSid);
|
||||||
return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye."));
|
return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.", locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiSettings = await storage.getAiSettings(session.userId);
|
const aiSettings = await storage.getAiSettings(session.userId);
|
||||||
const activeAi = resolveAiProvider(aiSettings ?? {});
|
const activeAi = resolveAiProvider(aiSettings ?? {});
|
||||||
if (!activeAi) {
|
if (!activeAi) {
|
||||||
aiCallSessions.delete(CallSid);
|
aiCallSessions.delete(CallSid);
|
||||||
return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye."));
|
return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.", locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
const patientRow = await db.patient.findUnique({ where: { id: session.patientId }, select: { firstName: true } });
|
const stage = await getStage(session.userId, session.patientId);
|
||||||
const ctx = (session as any).ctx || {};
|
const routed = await routePatientTurn({
|
||||||
const reply = await runVoiceAssistantTurn(
|
patient: {
|
||||||
session.history,
|
id: session.patientId, userId: session.userId,
|
||||||
{ firstName: patientRow?.firstName || "", ...ctx },
|
phone: session.phone, preferredLanguage: session.language,
|
||||||
activeAi.key, activeAi.provider, activeAi.model
|
},
|
||||||
);
|
stage, message: SpeechResult.trim(), activeAi,
|
||||||
|
chatTemplates: session.chatTemplates, officeName: session.officeName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reply = routed.segments.join(" ").trim() ||
|
||||||
|
"Sorry, I didn't quite catch that — could you say that again?";
|
||||||
|
|
||||||
session.history.push({ role: "assistant", text: reply });
|
session.history.push({ role: "assistant", text: reply });
|
||||||
await storage.createCommunication({
|
await storage.createCommunication({
|
||||||
@@ -1358,13 +796,13 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
|
|||||||
direction: "outbound", status: "completed", body: reply, twilioSid: CallSid,
|
direction: "outbound", status: "completed", body: reply, twilioSid: CallSid,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (soundsLikeGoodbye(reply)) {
|
if (routed.nextStage === "done" || soundsLikeGoodbye(reply)) {
|
||||||
aiCallSessions.delete(CallSid);
|
aiCallSessions.delete(CallSid);
|
||||||
return res.send(aiVoiceHangup(reply));
|
return res.send(aiVoiceHangup(reply, locale));
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${session.patientId}`;
|
const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${session.patientId}`;
|
||||||
return res.send(aiVoiceTwiml(reply, actionUrl));
|
return res.send(aiVoiceTwiml(reply, actionUrl, locale));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye."));
|
return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye."));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,11 +77,10 @@ export const twilioStorage = {
|
|||||||
generalFallback: all["_ai_chat_general_fallback"] ?? "",
|
generalFallback: all["_ai_chat_general_fallback"] ?? "",
|
||||||
rescheduleGreeting: all["_ai_chat_reschedule_greeting"] ?? "",
|
rescheduleGreeting: all["_ai_chat_reschedule_greeting"] ?? "",
|
||||||
reminderSms: all["_ai_chat_reminder_sms"] ?? "",
|
reminderSms: all["_ai_chat_reminder_sms"] ?? "",
|
||||||
callTemplate: all["_ai_chat_call_template"] ?? "",
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string; reminderSms?: string; callTemplate?: string }) {
|
async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string; reminderSms?: string }) {
|
||||||
const settings = await db.twilioSettings.findUnique({ where: { userId } });
|
const settings = await db.twilioSettings.findUnique({ where: { userId } });
|
||||||
const existing = (settings?.templates as Record<string, string>) || {};
|
const existing = (settings?.templates as Record<string, string>) || {};
|
||||||
const updated: Record<string, string> = { ...existing };
|
const updated: Record<string, string> = { ...existing };
|
||||||
@@ -90,7 +89,37 @@ export const twilioStorage = {
|
|||||||
if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback;
|
if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback;
|
||||||
if (templates.rescheduleGreeting !== undefined) updated["_ai_chat_reschedule_greeting"] = templates.rescheduleGreeting;
|
if (templates.rescheduleGreeting !== undefined) updated["_ai_chat_reschedule_greeting"] = templates.rescheduleGreeting;
|
||||||
if (templates.reminderSms !== undefined) updated["_ai_chat_reminder_sms"] = templates.reminderSms;
|
if (templates.reminderSms !== undefined) updated["_ai_chat_reminder_sms"] = templates.reminderSms;
|
||||||
if (templates.callTemplate !== undefined) updated["_ai_chat_call_template"] = templates.callTemplate;
|
return db.twilioSettings.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: { templates: updated },
|
||||||
|
create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Call Templates — wording spoken during live AI phone calls, kept
|
||||||
|
// separate from the SMS wording above (routePatientTurn() consumes
|
||||||
|
// whichever set the caller passes in). ────────────────────────────────────
|
||||||
|
|
||||||
|
async getAiCallTemplates(userId: number): Promise<Record<string, string>> {
|
||||||
|
const settings = await db.twilioSettings.findUnique({ where: { userId } });
|
||||||
|
const all = (settings?.templates as Record<string, string>) || {};
|
||||||
|
return {
|
||||||
|
greeting: all["_ai_call_greeting"] ?? all["_ai_chat_call_template"] ??
|
||||||
|
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?",
|
||||||
|
reminderGreeting: all["_ai_call_reminder_greeting"] ?? "Sure, I can help with that.",
|
||||||
|
newPatientGreeting: all["_ai_call_new_patient_greeting"] ?? "No problem, let's get you set up.",
|
||||||
|
generalFallback: all["_ai_call_general_fallback"] ?? "Sure, how can I help?",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveAiCallTemplates(userId: number, templates: { greeting?: string; 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.greeting !== undefined) updated["_ai_call_greeting"] = templates.greeting;
|
||||||
|
if (templates.reminderGreeting !== undefined) updated["_ai_call_reminder_greeting"] = templates.reminderGreeting;
|
||||||
|
if (templates.newPatientGreeting !== undefined) updated["_ai_call_new_patient_greeting"] = templates.newPatientGreeting;
|
||||||
|
if (templates.generalFallback !== undefined) updated["_ai_call_general_fallback"] = templates.generalFallback;
|
||||||
return db.twilioSettings.upsert({
|
return db.twilioSettings.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
update: { templates: updated },
|
update: { templates: updated },
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ type AiChatTemplates = {
|
|||||||
reminderGreeting: string;
|
reminderGreeting: string;
|
||||||
newPatientGreeting: string;
|
newPatientGreeting: string;
|
||||||
generalFallback: string;
|
generalFallback: string;
|
||||||
callTemplate: string;
|
};
|
||||||
|
|
||||||
|
type AiCallTemplates = {
|
||||||
|
greeting: string;
|
||||||
|
reminderGreeting: string;
|
||||||
|
newPatientGreeting: string;
|
||||||
|
generalFallback: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OfficeContact = {
|
type OfficeContact = {
|
||||||
@@ -37,8 +43,14 @@ const DEFAULTS = {
|
|||||||
"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?",
|
"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:
|
generalFallback:
|
||||||
"Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?",
|
"Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?",
|
||||||
callTemplate:
|
};
|
||||||
|
|
||||||
|
const CALL_DEFAULTS = {
|
||||||
|
greeting:
|
||||||
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?",
|
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?",
|
||||||
|
reminderGreeting: "Sure, I can help with that.",
|
||||||
|
newPatientGreeting: "No problem, let's get you set up.",
|
||||||
|
generalFallback: "Sure, how can I help?",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SMS_TEMPLATES = [
|
const DEFAULT_SMS_TEMPLATES = [
|
||||||
@@ -1012,9 +1024,14 @@ export function AiChatSettingsCard() {
|
|||||||
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
|
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
|
||||||
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
|
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
|
||||||
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
|
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
|
||||||
const [callTemplate, setCallTemplate] = useState(DEFAULTS.callTemplate);
|
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
const [callGreeting, setCallGreeting] = useState(CALL_DEFAULTS.greeting);
|
||||||
|
const [callReminderGreeting, setCallReminderGreeting] = useState(CALL_DEFAULTS.reminderGreeting);
|
||||||
|
const [callNewPatientGreeting, setCallNewPatientGreeting] = useState(CALL_DEFAULTS.newPatientGreeting);
|
||||||
|
const [callGeneralFallback, setCallGeneralFallback] = useState(CALL_DEFAULTS.generalFallback);
|
||||||
|
const callInitialized = useRef(false);
|
||||||
|
|
||||||
const [openPhoneReply, setOpenPhoneReply] = useState(false);
|
const [openPhoneReply, setOpenPhoneReply] = useState(false);
|
||||||
|
|
||||||
const { data: advancedSettings } = useQuery<{ openPhoneReply: boolean }>({
|
const { data: advancedSettings } = useQuery<{ openPhoneReply: boolean }>({
|
||||||
@@ -1091,10 +1108,31 @@ export function AiChatSettingsCard() {
|
|||||||
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
|
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
|
||||||
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
|
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
|
||||||
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
|
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
|
||||||
setCallTemplate(templates.callTemplate || DEFAULTS.callTemplate);
|
|
||||||
}
|
}
|
||||||
}, [templates]);
|
}, [templates]);
|
||||||
|
|
||||||
|
const { data: callTemplatesData, isLoading: callTemplatesLoading } = useQuery<AiCallTemplates>({
|
||||||
|
queryKey: ["/api/ai/call-templates"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest("GET", "/api/ai/call-templates");
|
||||||
|
if (!res.ok) throw new Error("Failed to load call templates");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed AI call templates
|
||||||
|
useEffect(() => {
|
||||||
|
if (callTemplatesData && !callInitialized.current) {
|
||||||
|
callInitialized.current = true;
|
||||||
|
setCallGreeting(callTemplatesData.greeting || CALL_DEFAULTS.greeting);
|
||||||
|
setCallReminderGreeting(callTemplatesData.reminderGreeting || CALL_DEFAULTS.reminderGreeting);
|
||||||
|
setCallNewPatientGreeting(callTemplatesData.newPatientGreeting || CALL_DEFAULTS.newPatientGreeting);
|
||||||
|
setCallGeneralFallback(callTemplatesData.generalFallback || CALL_DEFAULTS.generalFallback);
|
||||||
|
}
|
||||||
|
}, [callTemplatesData]);
|
||||||
|
|
||||||
// Seed SMS template list — fall back to the saved reminderSms if list is empty
|
// Seed SMS template list — fall back to the saved reminderSms if list is empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (smsTemplateListData && !smsInitialized.current) {
|
if (smsTemplateListData && !smsInitialized.current) {
|
||||||
@@ -1125,6 +1163,24 @@ export function AiChatSettingsCard() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveCallMutation = useMutation({
|
||||||
|
mutationFn: async (data: AiCallTemplates) => {
|
||||||
|
const res = await apiRequest("PUT", "/api/ai/call-templates", data);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null);
|
||||||
|
throw new Error(err?.message || "Failed to save call templates");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/ai/call-templates"] });
|
||||||
|
toast({ title: "Templates saved", description: "AI call templates have been updated." });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast({ title: "Error", description: err?.message, variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const saveSmsListMutation = useMutation({
|
const saveSmsListMutation = useMutation({
|
||||||
mutationFn: async (list: SmsTemplate[]) => {
|
mutationFn: async (list: SmsTemplate[]) => {
|
||||||
const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list);
|
const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list);
|
||||||
@@ -1149,7 +1205,16 @@ export function AiChatSettingsCard() {
|
|||||||
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
||||||
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
||||||
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
||||||
callTemplate: callTemplate.trim() || DEFAULTS.callTemplate,
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCallSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
saveCallMutation.mutate({
|
||||||
|
greeting: callGreeting.trim() || CALL_DEFAULTS.greeting,
|
||||||
|
reminderGreeting: callReminderGreeting.trim() || CALL_DEFAULTS.reminderGreeting,
|
||||||
|
newPatientGreeting: callNewPatientGreeting.trim() || CALL_DEFAULTS.newPatientGreeting,
|
||||||
|
generalFallback: callGeneralFallback.trim() || CALL_DEFAULTS.generalFallback,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1197,14 +1262,44 @@ export function AiChatSettingsCard() {
|
|||||||
onChange: setGeneralFallback,
|
onChange: setGeneralFallback,
|
||||||
placeholder: DEFAULTS.generalFallback,
|
placeholder: DEFAULTS.generalFallback,
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const callTemplateFields = [
|
||||||
{
|
{
|
||||||
key: "call",
|
key: "callGreeting",
|
||||||
icon: <PhoneCall className="h-4 w-4 text-primary" />,
|
icon: <PhoneCall className="h-4 w-4 text-primary" />,
|
||||||
label: "Call Template",
|
label: "Call Opening Greeting",
|
||||||
description: "Lisa's opening line when placing an AI Call to a patient. Separate from the SMS chat templates above — use {firstName} and {officeName} as placeholders.",
|
description: "Lisa's opening line when placing an AI Call to a patient — use {firstName} and {officeName} as placeholders.",
|
||||||
value: callTemplate,
|
value: callGreeting,
|
||||||
onChange: setCallTemplate,
|
onChange: setCallGreeting,
|
||||||
placeholder: DEFAULTS.callTemplate,
|
placeholder: CALL_DEFAULTS.greeting,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "callReminder",
|
||||||
|
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
|
||||||
|
label: "Reminder Call Reply",
|
||||||
|
description: "Spoken after the call's opening greeting when the AI is following up on an appointment reminder — keep it short, it's read aloud, not texted.",
|
||||||
|
value: callReminderGreeting,
|
||||||
|
onChange: setCallReminderGreeting,
|
||||||
|
placeholder: CALL_DEFAULTS.reminderGreeting,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "callNewPatient",
|
||||||
|
icon: <UserPlus className="h-4 w-4 text-primary" />,
|
||||||
|
label: "New Patient Call Reply",
|
||||||
|
description: "Spoken when the AI is walking a new patient through scheduling on a live call.",
|
||||||
|
value: callNewPatientGreeting,
|
||||||
|
onChange: setCallNewPatientGreeting,
|
||||||
|
placeholder: CALL_DEFAULTS.newPatientGreeting,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "callGeneral",
|
||||||
|
icon: <MessageCircle className="h-4 w-4 text-primary" />,
|
||||||
|
label: "General Call Fallback",
|
||||||
|
description: "Spoken when the AI cannot determine the context of what the patient said on a call.",
|
||||||
|
value: callGeneralFallback,
|
||||||
|
onChange: setCallGeneralFallback,
|
||||||
|
placeholder: CALL_DEFAULTS.generalFallback,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1408,7 +1503,85 @@ export function AiChatSettingsCard() {
|
|||||||
setReminderGreeting(DEFAULTS.reminderGreeting);
|
setReminderGreeting(DEFAULTS.reminderGreeting);
|
||||||
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
|
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
|
||||||
setGeneralFallback(DEFAULTS.generalFallback);
|
setGeneralFallback(DEFAULTS.generalFallback);
|
||||||
setCallTemplate(DEFAULTS.callTemplate);
|
}}
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Section 1b: Call Templates ───────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-6 space-y-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PhoneCall className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Call Templates</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Customize what Lisa says on live AI phone calls — kept separate from the SMS wording
|
||||||
|
above since spoken conversation reads differently than a text message. Use{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
|
||||||
|
and{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{firstName}"}</code>{" "}
|
||||||
|
as placeholders.
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callTemplatesLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading templates...</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleCallSubmit} className="space-y-5">
|
||||||
|
{callTemplateFields.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={saveCallMutation.isPending}
|
||||||
|
className="bg-teal-600 hover:bg-teal-700 text-white"
|
||||||
|
>
|
||||||
|
{saveCallMutation.isPending ? "Saving..." : "Save Templates"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setCallGreeting(CALL_DEFAULTS.greeting);
|
||||||
|
setCallReminderGreeting(CALL_DEFAULTS.reminderGreeting);
|
||||||
|
setCallNewPatientGreeting(CALL_DEFAULTS.newPatientGreeting);
|
||||||
|
setCallGeneralFallback(CALL_DEFAULTS.generalFallback);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reset to defaults
|
Reset to defaults
|
||||||
|
|||||||
Reference in New Issue
Block a user