- Reset conversation stage to a fresh start on every new voice call (recall and reminder alike), and on every hangup/timeout/goodbye, so an abandoned or completed call never leaves a patient stuck mid-flow for their next call or text. - Add a staleness check to the SMS flow: after a long idle gap, check whether the patient's reply still reads as a continuation before resuming the stored stage, falling back to a friendly "it's been a while" re-greeting when unsure. - Fix parseTime() to handle Twilio's speech-to-text output, which renders "pm"/"am" with periods and often drops the colon in a spoken time (e.g. "100 p.m." -> 1:00 PM) — this was silently failing and causing the AI to loop asking for a time it had already been given. - Extend the voice Gather timeout so the AI doesn't hang up on a patient who just needs a few seconds to respond. - Reword the recall-call opening line and rework the post-"yes" prompt to ask for day and time together. - Fix rclone WebDAV server to serve only the backups folder instead of the entire backend app directory (was exposing .env and source). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1134 lines
63 KiB
TypeScript
1134 lines
63 KiB
TypeScript
import { getLlm, type AiProvider } from "./llm-factory";
|
||
import { prisma as db } from "@repo/db/client";
|
||
import { storage } from "../storage";
|
||
import {
|
||
type ConversationStage,
|
||
setPendingReschedule,
|
||
getPendingReschedule,
|
||
clearPendingReschedule,
|
||
} from "./aiHandoffStore";
|
||
|
||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||
|
||
const MONTHS = [
|
||
"January","February","March","April","May","June",
|
||
"July","August","September","October","November","December",
|
||
];
|
||
const DAYS = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
|
||
|
||
function formattedDate(d: Date): string {
|
||
return `${DAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
||
}
|
||
|
||
function getTomorrow(): string {
|
||
const t = new Date();
|
||
t.setDate(t.getDate() + 1);
|
||
return formattedDate(t);
|
||
}
|
||
|
||
function getNextWeekDays(): { mon: string; tue: string; wed: string } {
|
||
const today = new Date();
|
||
const dow = today.getDay();
|
||
const daysToMon = (8 - dow) % 7 || 7;
|
||
const mon = new Date(today); mon.setDate(today.getDate() + daysToMon);
|
||
const tue = new Date(mon); tue.setDate(mon.getDate() + 1);
|
||
const wed = new Date(mon); wed.setDate(mon.getDate() + 2);
|
||
return { mon: formattedDate(mon), tue: formattedDate(tue), wed: formattedDate(wed) };
|
||
}
|
||
|
||
function getTomorrowDate(): { date: Date; label: string } {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() + 1);
|
||
d.setUTCHours(0, 0, 0, 0);
|
||
return { date: d, label: formattedDate(new Date(d.getTime() + d.getTimezoneOffset() * 60000)) };
|
||
}
|
||
|
||
function getNextWeekDateObjects() {
|
||
const today = new Date();
|
||
const dow = today.getDay();
|
||
const daysToMon = (8 - dow) % 7 || 7;
|
||
const mkDate = (offset: number) => {
|
||
const d = new Date(today);
|
||
d.setDate(today.getDate() + offset);
|
||
d.setUTCHours(0, 0, 0, 0);
|
||
const label = formattedDate(new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset));
|
||
return { date: d, label };
|
||
};
|
||
return { mon: mkDate(daysToMon), tue: mkDate(daysToMon + 1), wed: mkDate(daysToMon + 2) };
|
||
}
|
||
|
||
/** Format "HH:MM" (24-h) → "H:MM am/pm" */
|
||
export function timeLabel(hhmm: string): string {
|
||
const [h, m] = hhmm.split(":").map(Number);
|
||
const h12 = (h! % 12) || 12;
|
||
const ampm = (h! >= 12) ? "pm" : "am";
|
||
return `${h12}:${String(m!).padStart(2, "0")} ${ampm}`;
|
||
}
|
||
|
||
// ── Datetime helpers ─────────────────────────────────────────────────────────
|
||
|
||
/** Returns true if the message contains an explicit time (not just a date). */
|
||
function messageHasTime(msg: string): boolean {
|
||
return (
|
||
/\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/i.test(msg) ||
|
||
/\b([01]?\d|2[0-3]):[0-5]\d\b/.test(msg) ||
|
||
/\b(morning|afternoon|evening|mañana|tarde|manhã|上午|下午|صباح|مساء|maten|aprèmidi)\b/i.test(msg)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Parse only a date (no time) from a message like "5/18" or "next Monday".
|
||
* Returns a UTC-midnight Date and a date-only display label, or null.
|
||
*/
|
||
export async function parseDateOnlyFromMessage(
|
||
message: string,
|
||
apiKey: string,
|
||
provider: AiProvider = "google",
|
||
model?: string
|
||
): Promise<{ date: Date; dateLabel: string } | null> {
|
||
const now = new Date();
|
||
const todayStr = now.toLocaleDateString("en-CA");
|
||
|
||
// Regex: M/D, M-D, MM/DD patterns
|
||
const slashMatch = message.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/);
|
||
if (slashMatch) {
|
||
const mo = parseInt(slashMatch[1]!);
|
||
const dy = parseInt(slashMatch[2]!);
|
||
let yr = slashMatch[3]
|
||
? (slashMatch[3].length === 2 ? 2000 + parseInt(slashMatch[3]) : parseInt(slashMatch[3]))
|
||
: now.getFullYear();
|
||
const candidate = new Date(`${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}T00:00:00`);
|
||
if (candidate <= now) yr += 1;
|
||
const dateStr = `${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}`;
|
||
const parsedDate = new Date(dateStr + "T00:00:00.000Z");
|
||
if (!isNaN(parsedDate.getTime())) {
|
||
const local = new Date(dateStr + "T00:00:00");
|
||
const dateLabel = `${DAYS[local.getDay()]!}, ${MONTHS[local.getMonth()]!} ${local.getDate()}`;
|
||
return { date: parsedDate, dateLabel };
|
||
}
|
||
}
|
||
|
||
// LLM fallback for "next Monday", "Tuesday", etc.
|
||
try {
|
||
const llm = getLlm(provider, apiKey, model);
|
||
const res = await llm.invoke([
|
||
{
|
||
role: "system",
|
||
content:
|
||
`Today is ${todayStr}. Extract only the date from the patient message.
|
||
Return ONLY raw JSON: {"date":"YYYY-MM-DD"}
|
||
- date must be a future date after ${todayStr}.
|
||
- For relative names like "Monday" or "next Tuesday", use the next upcoming calendar date.
|
||
- If no valid future date found, return {"date":null}.`,
|
||
},
|
||
{ role: "user", content: message },
|
||
]);
|
||
const jsonMatch = String(res.content).match(/\{[\s\S]*?"date"[\s\S]*?\}/);
|
||
if (!jsonMatch) return null;
|
||
const json = JSON.parse(jsonMatch[0]);
|
||
if (!json.date || json.date === "null") return null;
|
||
const dateStr = String(json.date).trim();
|
||
const parsedDate = new Date(dateStr + "T00:00:00.000Z");
|
||
if (isNaN(parsedDate.getTime())) return null;
|
||
const local = new Date(dateStr + "T00:00:00");
|
||
const dateLabel = `${DAYS[local.getDay()]!}, ${MONTHS[local.getMonth()]!} ${local.getDate()}`;
|
||
return { date: parsedDate, dateLabel };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── Datetime parsing ──────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Parse a natural-language date + time preference from a patient SMS.
|
||
* Returns a future Date (UTC midnight), a 24-h startTime string, and a display label.
|
||
*/
|
||
async function parseDatetimeFromMessage(
|
||
message: string,
|
||
apiKey: string,
|
||
provider: AiProvider = "google",
|
||
model?: string
|
||
): Promise<{ date: Date; startTime: string; displayLabel: string } | null> {
|
||
const now = new Date();
|
||
const todayStr = now.toLocaleDateString("en-CA"); // YYYY-MM-DD local
|
||
|
||
// ── Step 1: regex pre-processing for common formats ───────────────────────
|
||
// Extract time (handles "10am", "10:30am", "2 pm", "14:00", "morning", "afternoon")
|
||
let preTime: string | null = null;
|
||
const ampmMatch = message.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i);
|
||
if (ampmMatch) {
|
||
let h = parseInt(ampmMatch[1]!);
|
||
const m = ampmMatch[2] ? parseInt(ampmMatch[2]) : 0;
|
||
const period = ampmMatch[3]!.toLowerCase();
|
||
if (period === "pm" && h < 12) h += 12;
|
||
if (period === "am" && h === 12) h = 0;
|
||
preTime = `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
|
||
} else if (/\bmorning\b/i.test(message)) preTime = "09:00";
|
||
else if (/\bafternoon\b/i.test(message)) preTime = "13:00";
|
||
|
||
// Extract date for M/D, M-D, MM/DD, MM-DD, M/D/YYYY patterns
|
||
let preDate: string | null = null;
|
||
const slashMatch = message.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](\d{2,4}))?\b/);
|
||
if (slashMatch) {
|
||
const mo = parseInt(slashMatch[1]!);
|
||
const dy = parseInt(slashMatch[2]!);
|
||
let yr = slashMatch[3]
|
||
? (slashMatch[3].length === 2 ? 2000 + parseInt(slashMatch[3]) : parseInt(slashMatch[3]))
|
||
: now.getFullYear();
|
||
// If the resulting date is in the past, assume next year
|
||
const candidate = new Date(`${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}T00:00:00`);
|
||
if (candidate <= now) yr += 1;
|
||
preDate = `${yr}-${String(mo).padStart(2,"0")}-${String(dy).padStart(2,"0")}`;
|
||
}
|
||
|
||
// If regex resolved both date and time, return immediately — no AI call needed
|
||
if (preDate && preTime) {
|
||
const parsedDate = new Date(preDate + "T00:00:00.000Z");
|
||
if (!isNaN(parsedDate.getTime())) {
|
||
const localDate = new Date(preDate + "T00:00:00");
|
||
const displayLabel = `${DAYS[localDate.getDay()]!}, ${MONTHS[localDate.getMonth()]!} ${localDate.getDate()} at ${timeLabel(preTime)}`;
|
||
return { date: parsedDate, startTime: preTime, displayLabel };
|
||
}
|
||
}
|
||
|
||
// ── Step 2: fall back to AI for natural language ───────────────────────────
|
||
try {
|
||
const llm = getLlm(provider, apiKey, model);
|
||
const res = await llm.invoke([
|
||
{
|
||
role: "system",
|
||
content:
|
||
`Today is ${todayStr}. Extract the preferred appointment date and time from the patient's message.
|
||
Return ONLY a raw JSON object — no markdown, no code block, no explanation:
|
||
{"date":"YYYY-MM-DD","time":"HH:MM"}
|
||
Rules:
|
||
- date must be a future date after ${todayStr}, in YYYY-MM-DD format.
|
||
- time must be 24-hour HH:MM format. "morning" → "09:00", "afternoon" → "13:00".
|
||
- For relative names like "Monday" or "next Tuesday", use the next upcoming calendar date.
|
||
- If no time is mentioned, use "09:00".
|
||
- If no valid future date can be found, return {"date":null,"time":null}.`,
|
||
},
|
||
{ role: "user", content: message },
|
||
]);
|
||
|
||
// Robustly extract the JSON object from any wrapping text Gemini might add
|
||
const raw = String(res.content);
|
||
const jsonMatch = raw.match(/\{[\s\S]*?"date"[\s\S]*?\}/);
|
||
if (!jsonMatch) return null;
|
||
const json = JSON.parse(jsonMatch[0]);
|
||
if (!json.date || json.date === "null") return null;
|
||
|
||
// Normalise date to YYYY-MM-DD
|
||
const dateStr = String(json.date).trim();
|
||
const parsedDate = new Date(dateStr + "T00:00:00.000Z");
|
||
if (isNaN(parsedDate.getTime())) return null;
|
||
|
||
// Normalise time: accept "HH:MM", "H:MM", or "HH:MM AM/PM"
|
||
let startTime = preTime ?? "09:00"; // use regex-extracted time if available
|
||
if (!preTime && typeof json.time === "string") {
|
||
const rawTime = json.time.trim();
|
||
const t24 = rawTime.match(/^(\d{1,2}):(\d{2})(?:\s*(am|pm))?$/i);
|
||
if (t24) {
|
||
let h = parseInt(t24[1]!);
|
||
const m = parseInt(t24[2]!);
|
||
const per = t24[3]?.toLowerCase();
|
||
if (per === "pm" && h < 12) h += 12;
|
||
if (per === "am" && h === 12) h = 0;
|
||
startTime = `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
|
||
}
|
||
}
|
||
|
||
const localDate = new Date(dateStr + "T00:00:00");
|
||
const displayLabel = `${DAYS[localDate.getDay()]!}, ${MONTHS[localDate.getMonth()]!} ${localDate.getDate()} at ${timeLabel(startTime)}`;
|
||
return { date: parsedDate, startTime, displayLabel };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── Availability checks ───────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Returns true if the given date+time is within the office's configured hours.
|
||
* Falls back to true (unrestricted) if no hours are configured.
|
||
*/
|
||
export async function isWithinOfficeHours(
|
||
date: Date,
|
||
time: string,
|
||
userId: number,
|
||
): Promise<boolean> {
|
||
try {
|
||
const record = await storage.getOfficeHours(userId);
|
||
if (!record?.data) return true;
|
||
|
||
const data = record.data as any;
|
||
const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const;
|
||
const dayName = dayNames[date.getUTCDay()]!;
|
||
|
||
// Override dates: all slots are open
|
||
const ymd = date.toISOString().split("T")[0]!;
|
||
if ((data.overrideDates as string[] | undefined)?.includes(ymd)) return true;
|
||
|
||
// Check both doctors' and hygienists' hours — if within either, it's valid
|
||
for (const group of [data.doctors, data.hygienists]) {
|
||
const slot = group?.[dayName];
|
||
if (!slot?.enabled) continue;
|
||
if (
|
||
(time >= slot.amStart && time < slot.amEnd) ||
|
||
(time >= slot.pmStart && time < slot.pmEnd)
|
||
) return true;
|
||
}
|
||
return false;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns whether the office is open at all on a given date (day-level check).
|
||
* Also returns the display day name (e.g. "Sunday") for use in error messages.
|
||
*/
|
||
export async function isOfficeDayOpen(
|
||
date: Date,
|
||
userId: number,
|
||
): Promise<{ open: boolean; displayDay: string }> {
|
||
const displayDays = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
|
||
const displayDay = displayDays[date.getUTCDay()]!;
|
||
try {
|
||
const record = await storage.getOfficeHours(userId);
|
||
if (!record?.data) return { open: true, displayDay };
|
||
|
||
const data = record.data as any;
|
||
const dayKeys = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const;
|
||
const dayKey = dayKeys[date.getUTCDay()]!;
|
||
|
||
// Override date — office is fully open
|
||
const ymd = date.toISOString().split("T")[0]!;
|
||
if ((data.overrideDates as string[] | undefined)?.includes(ymd)) return { open: true, displayDay };
|
||
|
||
// Open if at least one group (doctors or hygienists) has the day enabled
|
||
const doctorDay = data.doctors?.[dayKey];
|
||
const hygDay = data.hygienists?.[dayKey];
|
||
if (doctorDay?.enabled || hygDay?.enabled) return { open: true, displayDay };
|
||
|
||
return { open: false, displayDay };
|
||
} catch {
|
||
return { open: true, displayDay };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns the office hours for a given day as a human-readable string,
|
||
* e.g. "9:00 am – 12:00 pm and 1:00 pm – 5:00 pm".
|
||
* Returns null if no hours are configured or the day is closed.
|
||
*/
|
||
export async function getOfficeHoursDisplay(date: Date, userId: number): Promise<string | null> {
|
||
try {
|
||
const record = await storage.getOfficeHours(userId);
|
||
if (!record?.data) return null;
|
||
|
||
const data = record.data as any;
|
||
const dayKeys = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"] as const;
|
||
const dayKey = dayKeys[date.getUTCDay()]!;
|
||
|
||
for (const group of [data.doctors, data.hygienists]) {
|
||
const slot = group?.[dayKey];
|
||
if (!slot?.enabled) continue;
|
||
return `${timeLabel(slot.amStart)} – ${timeLabel(slot.amEnd)} and ${timeLabel(slot.pmStart)} – ${timeLabel(slot.pmEnd)}`;
|
||
}
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns true if no existing appointment for the patient's staff conflicts with
|
||
* the new date+startTime.
|
||
*/
|
||
async function isSlotAvailable(
|
||
patientId: number,
|
||
newDate: Date,
|
||
newStartTime: string,
|
||
): Promise<boolean> {
|
||
try {
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
const current = await db.appointment.findFirst({
|
||
where: { patientId, status: "scheduled", date: { gte: today } },
|
||
orderBy: { date: "asc" },
|
||
select: { staffId: true, startTime: true, endTime: true },
|
||
});
|
||
if (!current) return true;
|
||
|
||
// Compute new end time preserving original appointment duration
|
||
const [sh, sm] = current.startTime.split(":").map(Number);
|
||
const [eh, em] = current.endTime.split(":").map(Number);
|
||
const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!);
|
||
const [nh, nm] = newStartTime.split(":").map(Number);
|
||
const endTotal = nh! * 60 + nm! + durationMin;
|
||
const newEndTime = `${String(Math.floor(endTotal / 60)).padStart(2,"0")}:${String(endTotal % 60).padStart(2,"0")}`;
|
||
|
||
// Check for overlapping appointments for the same staff member on that day
|
||
const conflicts = await db.appointment.findMany({
|
||
where: {
|
||
staffId: current.staffId,
|
||
date: newDate,
|
||
status: { not: "cancelled" },
|
||
patientId: { not: patientId }, // exclude the patient's own appointment
|
||
},
|
||
select: { startTime: true, endTime: true },
|
||
});
|
||
|
||
for (const c of conflicts) {
|
||
if (newStartTime < c.endTime && newEndTime > c.startTime) return false;
|
||
}
|
||
return true;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// ── Time parsing (legacy) ─────────────────────────────────────────────────────
|
||
|
||
export async function parseTime(message: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string | null> {
|
||
// Twilio speech-to-text usually renders "pm"/"am" with periods ("p.m."),
|
||
// and often drops the colon in a spoken time too ("1:00pm" -> "100 pm",
|
||
// "12:30pm" -> "1230 pm") — normalize both before matching.
|
||
const t = message.toLowerCase()
|
||
.replace(/\ba\.m\.?/g, "am")
|
||
.replace(/\bp\.m\.?/g, "pm");
|
||
if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00";
|
||
if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00";
|
||
const ampm = t.match(/\b(\d{1,2}):([0-5]\d)\s*(am|pm)\b/);
|
||
const glued = !ampm ? t.match(/\b(\d{1,4})\s*(am|pm)\b/) : null;
|
||
const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/);
|
||
if (ampm) {
|
||
let h = parseInt(ampm[1]!);
|
||
const m = parseInt(ampm[2]!);
|
||
if (ampm[3] === "pm" && h < 12) h += 12;
|
||
if (ampm[3] === "am" && h === 12) h = 0;
|
||
return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
|
||
}
|
||
if (glued) {
|
||
const digits = glued[1]!;
|
||
let h: number, m: number;
|
||
if (digits.length <= 2) { h = parseInt(digits); m = 0; }
|
||
else { h = parseInt(digits.slice(0, -2)); m = parseInt(digits.slice(-2)); }
|
||
if (h >= 1 && h <= 12 && m <= 59) {
|
||
if (glued[2] === "pm" && h < 12) h += 12;
|
||
if (glued[2] === "am" && h === 12) h = 0;
|
||
return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
|
||
}
|
||
}
|
||
if (clock) return clock[0]!;
|
||
try {
|
||
const llm = getLlm(provider, apiKey, model);
|
||
const res = await llm.invoke([
|
||
{ role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format. If no time is mentioned, return "null".' },
|
||
{ role: "user", content: message },
|
||
]);
|
||
const raw = String(res.content).trim();
|
||
if (/^([01]?\d|2[0-3]):[0-5]\d$/.test(raw)) return raw;
|
||
} catch { /* fall through */ }
|
||
return null;
|
||
}
|
||
|
||
// ── Appointment update ────────────────────────────────────────────────────────
|
||
|
||
async function moveAppointment(
|
||
patientId: number,
|
||
newDate: Date,
|
||
newStartTime: string,
|
||
): Promise<"ok" | "no_appointment"> {
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
const appt = await db.appointment.findFirst({
|
||
where: { patientId, status: "scheduled", date: { gte: today } },
|
||
orderBy: { date: "asc" },
|
||
});
|
||
if (!appt) return "no_appointment";
|
||
|
||
const [sh, sm] = appt.startTime.split(":").map(Number);
|
||
const [eh, em] = appt.endTime.split(":").map(Number);
|
||
const durationMin = (eh! * 60 + em!) - (sh! * 60 + sm!);
|
||
const [nh, nm] = newStartTime.split(":").map(Number);
|
||
const endTotal = nh! * 60 + nm! + durationMin;
|
||
const newEndTime = `${String(Math.floor(endTotal / 60)).padStart(2,"0")}:${String(endTotal % 60).padStart(2,"0")}`;
|
||
|
||
await storage.updateAppointment(appt.id, {
|
||
date: newDate,
|
||
startTime: newStartTime,
|
||
endTime: newEndTime,
|
||
status: "scheduled",
|
||
movedByAi: true,
|
||
} as any);
|
||
|
||
return "ok";
|
||
}
|
||
|
||
// ── Intent classifiers ────────────────────────────────────────────────────────
|
||
|
||
function yes(t: string) {
|
||
return /\b(yes|yeah|yep|sure|ok|okay|please|absolutely|definitely|sí|si|sim|好的|نعم|wi|oke)\b/i.test(t);
|
||
}
|
||
function no(t: string) {
|
||
return /\b(no|nope|not|don't|won't|can't|لا|pa ka|não)\b/i.test(t);
|
||
}
|
||
function wantsAsap(t: string) {
|
||
return /\b(asap|soon|soonest|next day|tomorrow|quick|fastest|earliest|lo antes|mañana|amanhã|明天|明日|غدًا|demen)\b/i.test(t);
|
||
}
|
||
function wantsNextWeek(t: string) {
|
||
return /\b(next week|semana|prochain|prochaine|下周|下週|الأسبوع القادم|semèn pwochèn)\b/i.test(t);
|
||
}
|
||
function prefersMonday(t: string) { return /monday|lunes|segunda|周一|月曜|الاثنين|lendi/i.test(t); }
|
||
function prefersTuesday(t: string) { return /tuesday|martes|terça|周二|火曜|الثلاثاء|madi/i.test(t); }
|
||
function prefersWednesday(t: string) { return /wednesday|miércoles|quarta|周三|水曜|الأربعاء|mèkredi/i.test(t); }
|
||
|
||
// ── LLM helper ────────────────────────────────────────────────────────────────
|
||
|
||
async function llmReply(system: string, user: string, fallback: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string> {
|
||
try {
|
||
const llm = getLlm(provider, apiKey, model);
|
||
const res = await llm.invoke([
|
||
{ role: "system", content: system },
|
||
{ role: "user", content: user },
|
||
]);
|
||
return String(res.content).trim() || fallback;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
// ── Transfer fallback ─────────────────────────────────────────────────────────
|
||
|
||
const TRANSFER: Record<string, string> = {
|
||
English: "Our office staff will contact you shortly to help with rescheduling.",
|
||
Spanish: "El personal de nuestra oficina le contactará pronto para ayudarle a reprogramar.",
|
||
Portuguese: "Nossa equipe entrará em contato em breve para ajudá-lo a reagendar.",
|
||
Mandarin: "我们的工作人员将很快与您联系以帮助重新安排预约。",
|
||
Cantonese: "我們的工作人員將很快聯絡您以協助重新安排預約。",
|
||
Arabic: "سيتصل بك موظفو مكتبنا قريباً لمساعدتك في إعادة الجدولة.",
|
||
"Haitian Creole": "Anplwaye biwo nou yo pral kontakte ou byento pou ede ou repwograme.",
|
||
};
|
||
|
||
// ── Main step function ────────────────────────────────────────────────────────
|
||
|
||
export async function runRescheduleStep(
|
||
message: string,
|
||
stage: ConversationStage,
|
||
language: string,
|
||
patientId: number,
|
||
apiKey: string,
|
||
userId: number = 0,
|
||
provider: AiProvider = "google",
|
||
model?: string
|
||
): Promise<{ reply: string; nextStage: ConversationStage }> {
|
||
|
||
const lang = language || "English";
|
||
const t = message.toLowerCase();
|
||
const tx = TRANSFER[lang] ?? TRANSFER["English"]!;
|
||
|
||
// ── asked_reschedule_datetime ─────────────────────────────────────────────
|
||
// Patient provides date/time preference.
|
||
// If only a date is given, ask for the time. If both are given, ask to confirm.
|
||
if (stage === "asked_reschedule_datetime") {
|
||
const hasTime = messageHasTime(message);
|
||
|
||
if (!hasTime) {
|
||
// Try to parse date only
|
||
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
|
||
if (!parsedDate) {
|
||
const fallbacks: Record<string, string> = {
|
||
English: "I didn't catch that. What day would you prefer? For example: 'Monday', 'next Tuesday', or '5/18'.",
|
||
Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: 'lunes', 'próximo martes' o '18/5'.",
|
||
Portuguese: "Não entendi. Que dia você prefere? Por exemplo: 'Segunda', 'próxima terça' ou '18/5'.",
|
||
Mandarin: "我没听清。您希望哪天?例如:'星期一'、'下周二'或'5月18日'。",
|
||
Cantonese: "我沒聽清。您希望哪天?例如:'星期一'、'下週二'或'5月18日'。",
|
||
Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: 'الاثنين' أو '18/5'.",
|
||
"Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: 'Lendi', 'Madi pwochèn', oswa '5/18'.",
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
// Date found — check that the office is actually open that day
|
||
const { date, dateLabel } = parsedDate;
|
||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||
if (!dayCheck.open) {
|
||
const fallbacks: 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: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
// Day is open — save date and ask for time
|
||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel });
|
||
|
||
const fallbacks: 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}?`,
|
||
};
|
||
const askTimeReply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient wants to reschedule to ${dateLabel} but hasn't given a time. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`,
|
||
`Patient wants ${dateLabel} but gave no time.`,
|
||
fallbacks[lang] ?? fallbacks["English"]!,
|
||
apiKey, provider, model
|
||
);
|
||
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
|
||
}
|
||
|
||
// Both date and time present — parse the full datetime
|
||
const parsed = await parseDatetimeFromMessage(message, apiKey, provider, model);
|
||
if (!parsed) {
|
||
const fallbacks: Record<string, string> = {
|
||
English: "I didn't catch that. What day and time would you prefer? For example: 'Monday at 10am' or '5/18 at 2pm'.",
|
||
Spanish: "No entendí. ¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o '18/5 a las 2pm'.",
|
||
Portuguese: "Não entendi. Que dia e horário você prefere? Por exemplo: 'Segunda às 10h' ou '18/5 às 14h'.",
|
||
Mandarin: "我没听清。您希望哪天几点?例如:'星期一上午10点'或'5月18日下午2点'。",
|
||
Cantonese: "我沒聽清。您希望哪天幾點?例如:'星期一上午10點'或'5月18日下午2點'。",
|
||
Arabic: "لم أفهم. ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً'.",
|
||
"Haitian Creole": "Mwen pa konprann. Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am' oswa '5/18 2pm'.",
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
const { date, startTime, displayLabel } = parsed;
|
||
|
||
// Check the office is open on that day before confirming
|
||
const dayCheck2 = await isOfficeDayOpen(date, userId);
|
||
if (!dayCheck2.open) {
|
||
// Extract a date-only label for the message (strip the "at HH:MM" part)
|
||
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Our office is closed on ${datePart} (${dayCheck2.displayDay}). Can you please choose another day?`,
|
||
Spanish: `Nuestra oficina está cerrada el ${datePart} (${dayCheck2.displayDay}). ¿Puede elegir otro día?`,
|
||
Portuguese: `Nosso consultório está fechado em ${datePart} (${dayCheck2.displayDay}). Pode escolher outro dia?`,
|
||
Mandarin: `我们诊所在 ${datePart}(${dayCheck2.displayDay})不开放。请您选择另一天好吗?`,
|
||
Cantonese: `我們診所在 ${datePart}(${dayCheck2.displayDay})不開放。請您選擇另一天好嗎?`,
|
||
Arabic: `مكتبنا مغلق في ${datePart} (${dayCheck2.displayDay}). هل يمكنك اختيار يوم آخر؟`,
|
||
"Haitian Creole": `Biwo nou fèmen nan ${datePart} (${dayCheck2.displayDay}). Tanpri chwazi yon lòt jou?`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: displayLabel, startTime });
|
||
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Just to confirm — do you prefer ${displayLabel}?`,
|
||
Spanish: `Solo para confirmar — ¿prefiere el ${displayLabel}?`,
|
||
Portuguese: `Só para confirmar — você prefere ${displayLabel}?`,
|
||
Mandarin: `确认一下——您希望的时间是 ${displayLabel} 吗?`,
|
||
Cantonese: `確認一下——您希望的時間是 ${displayLabel} 嗎?`,
|
||
Arabic: `فقط للتأكيد — هل تفضل ${displayLabel}؟`,
|
||
"Haitian Creole": `Jis pou konfime — èske ou prefere ${displayLabel}?`,
|
||
};
|
||
const confirmReply = await llmReply(
|
||
`You are a friendly dental office AI assistant. The patient mentioned a date/time preference that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm: "Do you mean ${displayLabel}?" 1 sentence, natural and friendly. No formatting.`,
|
||
`Patient said: "${message}"`,
|
||
fallbacks[lang] ?? fallbacks["English"]!,
|
||
apiKey, provider, model
|
||
);
|
||
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
|
||
}
|
||
|
||
// ── asked_reschedule_time_for_date ────────────────────────────────────────
|
||
// Patient was asked "What time do you prefer on [date]?" and now replies with a time.
|
||
if (stage === "asked_reschedule_time_for_date") {
|
||
const pending = getPendingReschedule(userId, patientId);
|
||
if (!pending) {
|
||
const fallbacks: Record<string, string> = {
|
||
English: "Sorry, I lost track. What day and time would you prefer?",
|
||
Spanish: "Lo siento, perdí el hilo. ¿Qué día y hora prefiere?",
|
||
Portuguese: "Desculpe, perdi o fio. Que dia e horário você prefere?",
|
||
Mandarin: "抱歉,我失去了之前的信息。您希望哪天几点?",
|
||
Cantonese: "抱歉,我失去了之前的資訊。您希望哪天幾點?",
|
||
Arabic: "عذراً، فقدت المعلومات السابقة. ما اليوم والوقت الذي تفضله؟",
|
||
"Haitian Creole": "Regrèt, mwen pèdi trak. Ki jou ak ki lè ou prefere?",
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
// Parse the time from the patient's reply
|
||
const startTime = await parseTime(message, apiKey, provider, model);
|
||
if (!startTime) {
|
||
const fallbacks: Record<string, string> = {
|
||
English: `I didn't catch the time. What time would you prefer on ${pending.dayLabel}? For example: '10am', '2pm', or '1:30pm'.`,
|
||
Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am', '2pm' o '1:30pm'.`,
|
||
Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h', '14h' ou '13:30'.`,
|
||
Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点?例如:上午10点、下午2点或下午1:30。`,
|
||
Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點?例如:上午10點、下午2點或下午1:30。`,
|
||
Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`,
|
||
"Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am, 2pm, oswa 1:30pm.`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time_for_date" };
|
||
}
|
||
|
||
// Check that the requested time is within the office's working hours
|
||
const withinHours = await isWithinOfficeHours(pending.newDate, startTime, userId);
|
||
if (!withinHours) {
|
||
const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, userId);
|
||
const timeLbl = timeLabel(startTime);
|
||
const fallbacks: 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: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time_for_date" };
|
||
}
|
||
|
||
// Time is valid — build the full display label and ask for confirmation
|
||
const fullLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`;
|
||
setPendingReschedule(userId, patientId, { newDate: pending.newDate, dayLabel: fullLabel, startTime });
|
||
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Just to confirm — do you prefer ${fullLabel}?`,
|
||
Spanish: `Solo para confirmar — ¿prefiere el ${fullLabel}?`,
|
||
Portuguese: `Só para confirmar — você prefere ${fullLabel}?`,
|
||
Mandarin: `确认一下——您希望的时间是 ${fullLabel} 吗?`,
|
||
Cantonese: `確認一下——您希望的時間是 ${fullLabel} 嗎?`,
|
||
Arabic: `فقط للتأكيد — هل تفضل ${fullLabel}؟`,
|
||
"Haitian Creole": `Jis pou konfime — èske ou prefere ${fullLabel}?`,
|
||
};
|
||
const confirmReply = await llmReply(
|
||
`You are a friendly dental office AI assistant. The patient wants ${fullLabel}. Ask them in ${lang} to confirm: "Do you mean ${fullLabel}?" 1 sentence, natural and friendly. No formatting.`,
|
||
`Patient said: "${message}"`,
|
||
fallbacks[lang] ?? fallbacks["English"]!,
|
||
apiKey, provider, model
|
||
);
|
||
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
|
||
}
|
||
|
||
// ── asked_reschedule_confirm_datetime ─────────────────────────────────────
|
||
// Patient confirms (yes) or corrects (no) the parsed date/time.
|
||
if (stage === "asked_reschedule_confirm_datetime") {
|
||
const pending = getPendingReschedule(userId, patientId);
|
||
|
||
if (!pending) {
|
||
// State lost — ask again from scratch
|
||
const fallbacks: Record<string, string> = {
|
||
English: "Sorry, I lost track. What day and time would you prefer?",
|
||
Spanish: "Lo siento, perdí el hilo. ¿Qué día y hora prefiere?",
|
||
Portuguese: "Desculpe, perdi o fio. Que dia e horário você prefere?",
|
||
Mandarin: "抱歉,我失去了之前的信息。您希望哪天几点?",
|
||
Cantonese: "抱歉,我失去了之前的資訊。您希望哪天幾點?",
|
||
Arabic: "عذراً، فقدت المعلومات السابقة. ما اليوم والوقت الذي تفضله؟",
|
||
"Haitian Creole": "Regrèt, mwen pèdi trak. Ki jou ak ki lè ou prefere?",
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
// Patient said NO / wants to change
|
||
if (no(t)) {
|
||
clearPendingReschedule(userId, patientId);
|
||
const fallbacks: Record<string, string> = {
|
||
English: "No problem! What day and time would you prefer? For example: 'Monday at 10am' or '5/18 at 2pm'.",
|
||
Spanish: "¡Sin problema! ¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am'.",
|
||
Portuguese: "Sem problema! Que dia e horário você prefere? Por exemplo: 'Segunda às 10h'.",
|
||
Mandarin: "没关系!您希望哪天几点?例如:'星期一上午10点'。",
|
||
Cantonese: "沒問題!您希望哪天幾點?例如:'星期一上午10點'。",
|
||
Arabic: "لا بأس! ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً'.",
|
||
"Haitian Creole": "Pa gen pwoblèm! Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am'.",
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
// Patient said YES — proceed with availability check
|
||
const { newDate: date, startTime: pendingTime, dayLabel: displayLabel } = pending;
|
||
const startTime = pendingTime ?? "09:00";
|
||
clearPendingReschedule(userId, patientId);
|
||
|
||
// Check office hours — include actual hours in the message so patient knows what's available
|
||
const withinHours = await isWithinOfficeHours(date, startTime, userId);
|
||
if (!withinHours) {
|
||
const hoursDisplay = await getOfficeHoursDisplay(date, userId);
|
||
const timeLbl = timeLabel(startTime);
|
||
// Extract date portion for the message (strip "at HH:MM" if present)
|
||
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
|
||
const fallbacks: Record<string, string> = {
|
||
English: hoursDisplay
|
||
? `Our office is not available at ${timeLbl} on ${datePart}. Our hours are ${hoursDisplay}. What other time do you prefer?`
|
||
: `Our office is not available at ${timeLbl} on ${datePart}. What other time do you prefer?`,
|
||
Spanish: hoursDisplay
|
||
? `Nuestra oficina no está disponible a las ${timeLbl} el ${datePart}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
|
||
: `Nuestra oficina no está disponible a las ${timeLbl} el ${datePart}. ¿Qué otro horario prefiere?`,
|
||
Portuguese: hoursDisplay
|
||
? `Nosso consultório não está disponível às ${timeLbl} em ${datePart}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
|
||
: `Nosso consultório não está disponível às ${timeLbl} em ${datePart}. Que outro horário você prefere?`,
|
||
Mandarin: hoursDisplay
|
||
? `我们诊所在 ${datePart} ${timeLbl} 不开放。我们的工作时间是 ${hoursDisplay}。您希望改什么时间?`
|
||
: `我们诊所在 ${datePart} ${timeLbl} 不开放。您希望改什么时间?`,
|
||
Cantonese: hoursDisplay
|
||
? `我們診所在 ${datePart} ${timeLbl} 不開放。我們的工作時間是 ${hoursDisplay}。您希望改什麼時間?`
|
||
: `我們診所在 ${datePart} ${timeLbl} 不開放。您希望改什麼時間?`,
|
||
Arabic: hoursDisplay
|
||
? `مكتبنا غير متاح في ${timeLbl} يوم ${datePart}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
|
||
: `مكتبنا غير متاح في ${timeLbl} يوم ${datePart}. ما وقت آخر تفضله؟`,
|
||
"Haitian Creole": hoursDisplay
|
||
? `Biwo nou pa disponib a ${timeLbl} nan ${datePart}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
|
||
: `Biwo nou pa disponib a ${timeLbl} nan ${datePart}. Ki lòt lè ou prefere?`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
// Check slot availability
|
||
const available = await isSlotAvailable(patientId, date, startTime);
|
||
if (!available) {
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Sorry, ${displayLabel} is not available. What other time do you prefer?`,
|
||
Spanish: `Lo sentimos, ${displayLabel} no está disponible. ¿Qué otro horario prefiere?`,
|
||
Portuguese: `Infelizmente ${displayLabel} não está disponível. Que outro horário você prefere?`,
|
||
Mandarin: `抱歉,${displayLabel} 该时间段不可用。您有其他偏好的时间吗?`,
|
||
Cantonese: `抱歉,${displayLabel} 該時間段不可用。您有其他偏好的時間嗎?`,
|
||
Arabic: `عذراً، ${displayLabel} غير متاح. ما وقت آخر تفضله؟`,
|
||
"Haitian Creole": `Regrèt, ${displayLabel} pa disponib. Ki lòt lè ou prefere?`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
// Move the appointment
|
||
const result = await moveAppointment(patientId, date, startTime);
|
||
|
||
if (result === "no_appointment") {
|
||
const fallbacks: Record<string, string> = {
|
||
English: `I wasn't able to find your appointment to update. Our dental receptionist will contact you to confirm ${displayLabel}.`,
|
||
Spanish: `No encontré su cita. El personal le contactará para confirmar el ${displayLabel}.`,
|
||
Portuguese: `Não encontrei sua consulta. Nossa equipe confirmará ${displayLabel}.`,
|
||
Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${displayLabel}。`,
|
||
Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${displayLabel}。`,
|
||
Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${displayLabel}.`,
|
||
"Haitian Creole": `Mwen pa jwenn randevou ou. Anplwaye nou yo pral kontakte ou pou konfime ${displayLabel}.`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" };
|
||
}
|
||
|
||
// Success — confirm to patient
|
||
const fallback = `Your appointment is moved to ${displayLabel}. Our dental receptionist will confirm it with you tomorrow.`;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office AI assistant. The patient's appointment has been successfully moved to ${displayLabel}. Write a warm confirmation message in ${lang}: tell them the appointment is moved to ${displayLabel}, and that our dental receptionist will confirm it with them tomorrow. 2 sentences max, no formatting.`,
|
||
`Appointment rescheduled to ${displayLabel}.`,
|
||
fallback,
|
||
apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "done" };
|
||
}
|
||
|
||
// ── asked_reschedule_confirm: patient answered the reschedule question ────
|
||
if (stage === "asked_reschedule_confirm") {
|
||
if (no(t)) {
|
||
const fallbacks: Record<string, string> = {
|
||
English: "No problem! Feel free to reach out whenever you're ready. Have a great day!",
|
||
Spanish: "¡Sin problema! No dude en contactarnos cuando esté listo. ¡Que tenga un buen día!",
|
||
Portuguese: "Sem problema! Entre em contato quando estiver pronto. Tenha um ótimo dia!",
|
||
Mandarin: "没关系!随时联系我们。祝您有美好的一天!",
|
||
Cantonese: "沒問題!隨時聯繫我們。祝您有美好的一天!",
|
||
Arabic: "لا بأس! لا تتردد في التواصل معنا متى كنت مستعداً. أتمنى لك يوماً رائعاً!",
|
||
"Haitian Creole": "Pa gen pwoblèm! Kontakte nou nenpòt ki lè ou prèt. Pase yon bèl jounen!",
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient does not want to reschedule. Write a warm, brief closing message in ${lang}. 1 sentence, no formatting.`,
|
||
`Patient said: "${message}"`, fallback, apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "done" };
|
||
}
|
||
|
||
// Patient confirmed they want to reschedule.
|
||
// First check if they already included a date (and optionally time) in the same message.
|
||
if (yes(t)) {
|
||
const hasTime = messageHasTime(message);
|
||
|
||
if (hasTime) {
|
||
// Try to parse full datetime from the message
|
||
const parsed = await parseDatetimeFromMessage(message, apiKey, provider, model);
|
||
if (parsed) {
|
||
const { date, startTime, displayLabel } = parsed;
|
||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||
if (!dayCheck.open) {
|
||
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Our office is closed on ${datePart} (${dayCheck.displayDay}). Can you please choose another day?`,
|
||
Spanish: `Nuestra oficina está cerrada el ${datePart} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
|
||
Portuguese: `Nosso consultório está fechado em ${datePart} (${dayCheck.displayDay}). Pode escolher outro dia?`,
|
||
Mandarin: `我们诊所在 ${datePart}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
|
||
Cantonese: `我們診所在 ${datePart}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
|
||
Arabic: `مكتبنا مغلق في ${datePart} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
|
||
"Haitian Creole": `Biwo nou fèmen nan ${datePart} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: displayLabel, startTime });
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Just to confirm — do you prefer ${displayLabel}?`,
|
||
Spanish: `Solo para confirmar — ¿prefiere el ${displayLabel}?`,
|
||
Portuguese: `Só para confirmar — você prefere ${displayLabel}?`,
|
||
Mandarin: `确认一下——您希望的时间是 ${displayLabel} 吗?`,
|
||
Cantonese: `確認一下——您希望的時間是 ${displayLabel} 嗎?`,
|
||
Arabic: `فقط للتأكيد — هل تفضل ${displayLabel}؟`,
|
||
"Haitian Creole": `Jis pou konfime — èske ou prefere ${displayLabel}?`,
|
||
};
|
||
const confirmReply = await llmReply(
|
||
`You are a friendly dental office AI assistant. The patient mentioned a date/time that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm. 1 sentence, natural and friendly. No formatting.`,
|
||
`Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, provider
|
||
);
|
||
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
|
||
}
|
||
}
|
||
|
||
// Try to parse a date-only from the message
|
||
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
|
||
if (parsedDate) {
|
||
const { date, dateLabel } = parsedDate;
|
||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||
if (!dayCheck.open) {
|
||
const fallbacks: 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: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
// Day is open — save and ask for time
|
||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel });
|
||
const fallbacks: 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}?`,
|
||
};
|
||
const askTimeReply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient wants ${dateLabel}. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`,
|
||
`Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, provider
|
||
);
|
||
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
|
||
}
|
||
|
||
// No date found — ask for day and time
|
||
const fallbacks: Record<string, string> = {
|
||
English: "What day and time would you like? For example: 'Monday at 10am' or 'next Tuesday afternoon'.",
|
||
Spanish: "¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o 'martes por la tarde'.",
|
||
Portuguese: "Que dia e horário você prefere? Por exemplo: 'Segunda às 10h' ou 'terça de tarde'.",
|
||
Mandarin: "您希望哪天几点?例如:'星期一上午10点'或'下周二下午'。",
|
||
Cantonese: "您希望哪天幾點?例如:'星期一上午10點'或'下週二下午'。",
|
||
Arabic: "ما اليوم والوقت الذي تفضله؟ مثلاً: 'الاثنين الساعة 10 صباحاً' أو 'الثلاثاء بعد الظهر'.",
|
||
"Haitian Creole": "Ki jou ak ki lè ou prefere? Pa egzanp: 'Lendi 10am' oswa 'Madi apremidi'.",
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient wants to reschedule. Ask them in ${lang} what day and time they prefer. Give 1-2 examples like "Monday at 10am" or "next Tuesday afternoon". 1-2 sentences, no formatting.`,
|
||
`Patient wants to reschedule.`, fallback, apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_datetime" };
|
||
}
|
||
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|
||
|
||
// ── asked_reschedule_preference: patient chose ASAP or next week ──────────
|
||
if (stage === "asked_reschedule_preference") {
|
||
if (wantsAsap(t) || yes(t)) {
|
||
const tomorrow = getTomorrow();
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Can you come in tomorrow, ${tomorrow}?`,
|
||
Spanish: `¿Podría venir mañana, ${tomorrow}?`,
|
||
Portuguese: `Você pode vir amanhã, ${tomorrow}?`,
|
||
Mandarin: `您明天(${tomorrow})能来吗?`,
|
||
Cantonese: `您明天(${tomorrow})能來嗎?`,
|
||
Arabic: `هل يمكنك الحضور غداً، ${tomorrow}؟`,
|
||
"Haitian Creole": `Èske ou ka vini demen, ${tomorrow}?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. Ask the patient in ${lang} if they can come in tomorrow, ${tomorrow}. 1 sentence, no formatting.`,
|
||
`Patient wants to reschedule ASAP.`, fallback, apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_asap" };
|
||
}
|
||
|
||
if (wantsNextWeek(t)) {
|
||
const { mon, tue, wed } = getNextWeekDays();
|
||
const fallbacks: Record<string, string> = {
|
||
English: `I can schedule you for next week. Would ${mon}, ${tue}, or ${wed} work for you?`,
|
||
Spanish: `Puedo programarle para la semana que viene. ¿Le vendría bien el ${mon}, ${tue} o el ${wed}?`,
|
||
Portuguese: `Posso agendá-lo para a semana que vem. ${mon}, ${tue} ou ${wed} seria bom?`,
|
||
Mandarin: `我可以安排您在下周预约。${mon}、${tue} 或 ${wed} 方便吗?`,
|
||
Cantonese: `我可以安排您在下週預約。${mon}、${tue} 或 ${wed} 方便嗎?`,
|
||
Arabic: `يمكنني جدولتك للأسبوع القادم. هل ${mon} أو ${tue} أو ${wed} مناسب لك؟`,
|
||
"Haitian Creole": `Mwen ka pwograme ou pou semèn pwochèn. ${mon}, ${tue}, oswa ${wed} ka travay?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
|
||
`Patient prefers next week.`, fallback, apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||
}
|
||
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|
||
|
||
// ── asked_reschedule_asap: patient answered "Can you come tomorrow?" ───────
|
||
if (stage === "asked_reschedule_asap") {
|
||
if (yes(t)) {
|
||
const { date, label } = getTomorrowDate();
|
||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: label });
|
||
const fallbacks: Record<string, string> = {
|
||
English: `${label} it is! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`,
|
||
Spanish: `¡${label} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`,
|
||
Portuguese: `${label} ótimo! Você prefere manhã (9h–12h) ou tarde (13h–17h)?`,
|
||
Mandarin: `${label},太好了!您想预约上午(9点–12点)还是下午(1点–5点)?`,
|
||
Cantonese: `${label},太好了!您想預約上午(9點–12點)還是下午(1點–5點)?`,
|
||
Arabic: `${label} ممتاز! هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟`,
|
||
"Haitian Creole": `${label} pafè! Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient confirmed ${label}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
|
||
`Patient confirmed tomorrow.`, fallback, apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_time" };
|
||
}
|
||
if (no(t)) {
|
||
const { mon, tue, wed } = getNextWeekDays();
|
||
const fallbacks: Record<string, string> = {
|
||
English: `No problem! What about next week? Would ${mon}, ${tue}, or ${wed} work?`,
|
||
Spanish: `¡Sin problema! ¿Qué le parece la semana que viene? ¿El ${mon}, ${tue} o el ${wed}?`,
|
||
Portuguese: `Sem problema! E na semana que vem? ${mon}, ${tue} ou ${wed}?`,
|
||
Mandarin: `没关系!下周怎么样?${mon}、${tue} 或 ${wed} 方便吗?`,
|
||
Cantonese: `沒問題!下週怎麼樣?${mon}、${tue} 或 ${wed} 方便嗎?`,
|
||
Arabic: `لا بأس! ماذا عن الأسبوع القادم؟ ${mon} أو ${tue} أو ${wed}؟`,
|
||
"Haitian Creole": `Pa gen pwoblèm! Semèn pwochèn? ${mon}, ${tue}, oswa ${wed}?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`,
|
||
`Patient can't come tomorrow.`, fallback, apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_next_week" };
|
||
}
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|
||
|
||
// ── asked_reschedule_next_week: patient choosing Mon/Tue/Wed ─────────────
|
||
if (stage === "asked_reschedule_next_week") {
|
||
const nwd = getNextWeekDateObjects();
|
||
let chosen: { date: Date; label: string } | null = null;
|
||
if (prefersMonday(t)) chosen = nwd.mon;
|
||
if (prefersTuesday(t)) chosen = nwd.tue;
|
||
if (prefersWednesday(t)) chosen = nwd.wed;
|
||
|
||
if (chosen) {
|
||
setPendingReschedule(userId, patientId, { newDate: chosen.date, dayLabel: chosen.label });
|
||
const day = chosen.label;
|
||
const fallbacks: Record<string, string> = {
|
||
English: `${day} works! Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?`,
|
||
Spanish: `¡${day} perfecto! ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?`,
|
||
Portuguese: `${day} ótimo! Você prefere manhã (9h–12h) ou tarde (13h–17h)?`,
|
||
Mandarin: `${day},好的!您想预约上午(9点–12点)还是下午(1点–5点)?`,
|
||
Cantonese: `${day},好的!您想預約上午(9點–12點)還是下午(1點–5點)?`,
|
||
Arabic: `${day} رائع! هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟`,
|
||
"Haitian Creole": `${day} pafè! Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?`,
|
||
};
|
||
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient chose ${day}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
|
||
`Patient chose ${day}.`, fallback, apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "asked_reschedule_time" };
|
||
}
|
||
|
||
const { mon, tue, wed } = getNextWeekDays();
|
||
const fallbacks: Record<string, string> = {
|
||
English: `Which day works best — ${mon}, ${tue}, or ${wed}?`,
|
||
Spanish: `¿Qué día le viene mejor — el ${mon}, ${tue} o el ${wed}?`,
|
||
Portuguese: `Qual dia é melhor — ${mon}, ${tue} ou ${wed}?`,
|
||
Mandarin: `哪天最方便——${mon}、${tue} 还是 ${wed}?`,
|
||
Cantonese: `哪天最方便——${mon}、${tue} 還是 ${wed}?`,
|
||
Arabic: `أي يوم هو الأفضل — ${mon} أو ${tue} أو ${wed}؟`,
|
||
"Haitian Creole": `Ki jou ki pi bon — ${mon}, ${tue}, oswa ${wed}?`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_next_week" };
|
||
}
|
||
|
||
// ── asked_reschedule_time: patient picked morning / afternoon / specific time ──
|
||
if (stage === "asked_reschedule_time") {
|
||
const pending = getPendingReschedule(userId, patientId);
|
||
if (!pending) return { reply: tx, nextStage: "done" };
|
||
|
||
const startTime = await parseTime(message, apiKey, provider, model);
|
||
if (!startTime) {
|
||
const fallbacks: Record<string, string> = {
|
||
English: "I didn't catch the time. Would you prefer morning (9am–12pm) or afternoon (1pm–5pm)?",
|
||
Spanish: "No entendí la hora. ¿Prefiere la mañana (9am–12pm) o la tarde (1pm–5pm)?",
|
||
Portuguese: "Não entendi o horário. Você prefere manhã (9h–12h) ou tarde (13h–17h)?",
|
||
Mandarin: "我没听清时间。您想预约上午(9点–12点)还是下午(1点–5点)?",
|
||
Cantonese: "我沒聽清時間。您想預約上午(9點–12點)還是下午(1點–5點)?",
|
||
Arabic: "لم أفهم الوقت. هل تفضل الصباح (9ص–12م) أم بعد الظهر (1م–5م)؟",
|
||
"Haitian Creole": "Mwen pa konprann lè a. Èske ou prefere maten (9am–12pm) oswa apremidi (1pm–5pm)?",
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_time" };
|
||
}
|
||
|
||
const updateResult = await moveAppointment(patientId, pending.newDate, startTime);
|
||
clearPendingReschedule(userId, patientId);
|
||
|
||
const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`;
|
||
|
||
if (updateResult === "no_appointment") {
|
||
const fallbacks: Record<string, string> = {
|
||
English: `I couldn't find your appointment to update. Our staff will contact you to confirm ${apptLabel}.`,
|
||
Spanish: `No encontré su cita para actualizar. El personal le contactará para confirmar el ${apptLabel}.`,
|
||
Portuguese: `Não encontrei sua consulta. Nossa equipe entrará em contato para confirmar ${apptLabel}.`,
|
||
Mandarin: `我找不到您的预约进行更新。我们的工作人员将联系您确认${apptLabel}。`,
|
||
Cantonese: `我找不到您的預約進行更新。我們的工作人員將聯絡您確認${apptLabel}。`,
|
||
Arabic: `لم أجد موعدك لتحديثه. سيتصل بك موظفونا لتأكيد ${apptLabel}.`,
|
||
"Haitian Creole": `Mwen pa jwenn randevou ou. Anplwaye nou yo pral kontakte ou pou konfime ${apptLabel}.`,
|
||
};
|
||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "done" };
|
||
}
|
||
|
||
const fallback = `Your appointment has been moved to ${apptLabel}. Our dental receptionist will confirm it with you tomorrow.`;
|
||
const reply = await llmReply(
|
||
`You are a friendly dental office assistant. The patient's appointment has been rescheduled to ${apptLabel}. Confirm in ${lang}: say the appointment is moved to ${apptLabel} and that our dental receptionist will confirm it with them tomorrow. 2 sentences, no formatting.`,
|
||
`Appointment moved to ${apptLabel}.`, fallback, apiKey, provider, model
|
||
);
|
||
return { reply, nextStage: "done" };
|
||
}
|
||
|
||
return { reply: tx, nextStage: "done" };
|
||
}
|