Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 163 additions & 15 deletions api/src/routes/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1592,8 +1592,6 @@ function hasExplicitRescheduleIntent(input: string) {

return [
/\breschedul(?:e|ing)\b/,
/\bcancell?ing?\b/,
/\bcancel\b/,
/\bmove (my|the|an)?\s*appointment\b/,
/\bchange (my|the|an)?\s*appointment\b/,
/\bswitch (my|the|an)?\s*appointment\b/,
Expand All @@ -1603,6 +1601,27 @@ function hasExplicitRescheduleIntent(input: string) {
].some((pattern) => pattern.test(normalized));
}


function hasExplicitCancellationIntent(input: string) {
const normalized = String(input || "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
if (!normalized) return false;

return [
/\bcancel\b/,
/\bcanceled\b/,
/\bcancelled\b/,
/\bcanceling\b/,
/\bcancelling\b/,
/\bcancellation\b/,
/\bcall off (my|the|an)?\s*appointment\b/,
/\bdon't need (my|the|an)?\s*appointment\b/,
/\bdo not need (my|the|an)?\s*appointment\b/,
].some((pattern) => pattern.test(normalized));
}

function classifyOutboundTriageInput(
speech: string,
digits: string
Expand Down Expand Up @@ -8796,6 +8815,36 @@ router.post("/voice/intent", async (req, res) => {
return res.status(200).type("text/xml").send(twiml.toString());
};


const respondWithCancellationLaneStart = (
source: "intent" | "dtmf"
) => {
logVoiceOperationalStep({
eventName: "voice_flow_step",
callSid,
stage: "cancellation_start",
correlationId,
payload: {
source,
route: "hidden_cancellation_lane",
...buildRescheduleAssistantMetadata(),
},
});

const gather = twiml.gather({
input: ["speech", "dtmf"],
action: "/api/voice/reschedule?stage=cancel_lookup",
method: "POST",
speechTimeout: "auto",
});
say(
gather,
"I can send a cancellation request to the team. Your appointment is not canceled yet; staff will confirm before anything is canceled. What phone number should I use to look up your appointment?"
);
twiml.redirect("/api/voice/reschedule?stage=cancel_lookup");
return res.status(200).type("text/xml").send(twiml.toString());
};

const respondWithInlineRenewalCaptureStart = async (
source: "intent" | "dtmf"
) => {
Expand Down Expand Up @@ -12533,6 +12582,7 @@ router.post("/voice/intent", async (req, res) => {
const isLocationOrHours = isLocationOrHoursDigit || isLocationOrHoursSpeech;

const isReschedule = hasExplicitRescheduleIntent(normalizedSpeech);
const isCancellation = hasExplicitCancellationIntent(normalizedSpeech);

const isBooking =
digit === "1" ||
Expand Down Expand Up @@ -12568,6 +12618,7 @@ router.post("/voice/intent", async (req, res) => {

// isLocationOrHours already defined above for Press 3 submenu


if (isReschedule) {
logVoiceOperationalStep({
eventName: "voice_flow_step",
Expand All @@ -12579,6 +12630,17 @@ router.post("/voice/intent", async (req, res) => {
return respondWithRescheduleLaneStart("intent");
}

if (isCancellation && !isReschedule) {
logVoiceOperationalStep({
eventName: "voice_flow_step",
callSid,
stage: "option3_after_hours_cancellation_selected",
correlationId,
payload: { reason: "cancellation_inquiry" },
});
return respondWithCancellationLaneStart("intent");
}

if (isBooking) {
logVoiceOperationalStep({
eventName: "voice_flow_step",
Expand Down Expand Up @@ -12843,6 +12905,7 @@ router.post("/voice/intent", async (req, res) => {
return respondWithAnnualStateCardRenewalStart("dtmf");
}


if (digitChoice === "3" && utterance && hasExplicitRescheduleIntent(utterance)) {
logVoiceOperationalStep({
eventName: "voice_flow_step",
Expand All @@ -12858,6 +12921,38 @@ router.post("/voice/intent", async (req, res) => {
return respondWithRescheduleLaneStart("intent");
}

if (utterance && hasExplicitRescheduleIntent(utterance)) {
logVoiceOperationalStep({
eventName: "voice_flow_step",
callSid,
stage: "general_info_reschedule_request_detected",
correlationId,
payload: {
route: "hidden_reschedule_lane",
source: digitChoice === "3" ? "press_three" : "speech",
...buildRescheduleAssistantMetadata(),
},
});

return respondWithRescheduleLaneStart("intent");
}

if (digitChoice === "3" && utterance && hasExplicitCancellationIntent(utterance)) {
logVoiceOperationalStep({
eventName: "voice_flow_step",
callSid,
stage: "press_3_cancellation_request_detected",
correlationId,
payload: {
route: "hidden_cancellation_lane",
source: "press_three",
...buildRescheduleAssistantMetadata(),
},
});

return respondWithCancellationLaneStart("intent");
}

if (digitChoice === "3") {
const staffHandoffConfig = getStaffHandoffConfig();
const handoffReady = Boolean(
Expand Down Expand Up @@ -12972,20 +13067,21 @@ router.post("/voice/intent", async (req, res) => {
return respondWithRescheduleLaneStart("dtmf");
}

if (utterance && hasExplicitRescheduleIntent(utterance)) {

if (utterance && hasExplicitCancellationIntent(utterance) && !hasExplicitRescheduleIntent(utterance)) {
logVoiceOperationalStep({
eventName: "voice_flow_step",
callSid,
stage: "general_info_reschedule_request_detected",
stage: "general_info_cancellation_request_detected",
correlationId,
payload: {
route: "hidden_reschedule_lane",
route: "hidden_cancellation_lane",
source: digitChoice === "3" ? "press_three" : "speech",
...buildRescheduleAssistantMetadata(),
},
});

return respondWithRescheduleLaneStart("intent");
return respondWithCancellationLaneStart("intent");
}

const dtmfGeneralInfoTopicKey: GeneralInfoTopicKey | null =
Expand Down Expand Up @@ -14899,6 +14995,41 @@ router.post("/voice/reschedule", async (req, res) => {
},
});


if (stage === "cancel_lookup") {
const providedPhone = normalizePhone(String(speech || digits || "").trim());
const callbackPhone = providedPhone || callerPhone;
const callback = await createRescheduleFallback({
kind: "cancellation",
callSid,
correlationId,
phone: callbackPhone,
confidence: "none",
reason: "caller_requested_cancellation",
originalUtterance: speech || digits || "appointment cancellation request",
});

logVoiceOperationalStep({
eventName: "voice_flow_step",
callSid,
stage: "cancel_lookup",
correlationId,
payload: {
callbackId: callback.id,
phoneLast4: safeLast4Digits(callbackPhone),
},
});

await speakInboundWithElevenLabsBridgeOrFallback({
req,
target: twiml,
text: "Thanks. I captured the cancellation request. Your appointment is not canceled yet; staff will confirm before anything is canceled.",
context: { callSid, correlationId },
});
twiml.hangup();
return res.type("text/xml").send(twiml.toString());
}

// State machine for reschedule flow
if (stage === "start") {
// Product simplification: If callerPhone exists, don't ask for phone or do separate lookup.
Expand Down Expand Up @@ -16831,6 +16962,8 @@ async function createRescheduleFallback(params: {
matchedAppointmentChoices?: string[];
reason?: string;
requestedNewTime?: string;
kind?: "reschedule" | "cancellation";
originalUtterance?: string;
}) {
const {
callSid,
Expand All @@ -16850,10 +16983,16 @@ async function createRescheduleFallback(params: {
matchedAppointmentChoices,
reason,
requestedNewTime,
kind = "reschedule",
originalUtterance,
} = params;

const isCancellationRequest = kind === "cancellation";

const summaryLines = [
"Reschedule request via hidden IVR lane.",
isCancellationRequest
? "Cancellation request via hidden IVR lane."
: "Reschedule request via hidden IVR lane.",
`confidence=${confidence}`,
callSid ? `callSid=${callSid}` : "",
correlationId ? `correlationId=${correlationId}` : "",
Expand All @@ -16876,23 +17015,30 @@ async function createRescheduleFallback(params: {
matchedAppointmentType ? `matched_appointment_type=${matchedAppointmentType}` : "",
matchedAppointmentChoices?.length ? `multiple_appointment_choices=${matchedAppointmentChoices.join(" | ")}` : "",
reason ? `lookup_reason=${reason}` : "",
requestedNewTime ? `requested_new_time=${requestedNewTime}` : "",
"staff_action_required=confirm_reschedule",
requestedNewTime && !isCancellationRequest ? `requested_new_time=${requestedNewTime}` : "",
originalUtterance ? `original_utterance=${sanitizeVoiceSummaryToken(originalUtterance, 180)}` : "",
isCancellationRequest
? "staff_action_required=confirm_cancellation"
: "staff_action_required=confirm_reschedule",
"policy_disclosed=24hr_50_fee",
"policy_note=reschedules_or_cancellations_under_24hr_may_have_50_fee",
isCancellationRequest
? "policy_note=cancellations_under_24hr_may_have_50_fee"
: "policy_note=reschedules_or_cancellations_under_24hr_may_have_50_fee",
].filter(Boolean);

const callback = await prisma.callbackRequest.create({
data: {
name: name || "",
phone: phone || "",
status: "pending",
requestType: "reschedule",
scenarioType: "reschedule",
requestType: isCancellationRequest ? "cancellation" : "reschedule",
scenarioType: isCancellationRequest ? "cancellation" : "reschedule",
scenarioState: "pending",
source: "voice",
staffFollowupRequired: true,
nonMedicalReason: "reschedule_request",
nonMedicalReason: isCancellationRequest
? "appointment_cancellation_request"
: "reschedule_request",
summary: summaryLines.join("\n"),
},
});
Expand All @@ -16912,8 +17058,10 @@ async function createRescheduleFallback(params: {
});

sendVoiceEscalation({
intent: "reschedule",
reason: "reschedule_fallback",
intent: isCancellationRequest ? "appointment_cancellation" : "reschedule",
reason: isCancellationRequest
? "appointment_cancellation_fallback"
: "reschedule_fallback",
callSid: callSid || "",
phone: phone || null,
});
Expand Down
Loading
Loading