From e4c790b45419bd4d212e80bd05527ceaf29c62a8 Mon Sep 17 00:00:00 2001 From: CapuchaRojo Date: Fri, 8 May 2026 16:30:51 -0400 Subject: [PATCH 1/2] fix(voice): preserve cancellation intent in staff handoff --- api/src/routes/voice.ts | 170 ++++++++++++++++++++++++-- api/tests/voiceRescheduleLane.test.ts | 77 +++++++++++- ivr routing system.txt | 3 + 3 files changed, 238 insertions(+), 12 deletions(-) diff --git a/api/src/routes/voice.ts b/api/src/routes/voice.ts index ebf1558..6a90018 100644 --- a/api/src/routes/voice.ts +++ b/api/src/routes/voice.ts @@ -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/, @@ -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 @@ -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" ) => { @@ -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" || @@ -12568,6 +12618,18 @@ router.post("/voice/intent", async (req, res) => { // isLocationOrHours already defined above for Press 3 submenu + + if (isCancellation) { + logVoiceOperationalStep({ + eventName: "voice_flow_step", + callSid, + stage: "option3_after_hours_cancellation_selected", + correlationId, + payload: { reason: "cancellation_inquiry" }, + }); + return respondWithCancellationLaneStart("intent"); + } + if (isReschedule) { logVoiceOperationalStep({ eventName: "voice_flow_step", @@ -12843,6 +12905,23 @@ router.post("/voice/intent", async (req, res) => { return respondWithAnnualStateCardRenewalStart("dtmf"); } + + 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" && utterance && hasExplicitRescheduleIntent(utterance)) { logVoiceOperationalStep({ eventName: "voice_flow_step", @@ -12972,6 +13051,23 @@ router.post("/voice/intent", async (req, res) => { return respondWithRescheduleLaneStart("dtmf"); } + + if (utterance && hasExplicitCancellationIntent(utterance)) { + logVoiceOperationalStep({ + eventName: "voice_flow_step", + callSid, + stage: "general_info_cancellation_request_detected", + correlationId, + payload: { + route: "hidden_cancellation_lane", + source: digitChoice === "3" ? "press_three" : "speech", + ...buildRescheduleAssistantMetadata(), + }, + }); + + return respondWithCancellationLaneStart("intent"); + } + if (utterance && hasExplicitRescheduleIntent(utterance)) { logVoiceOperationalStep({ eventName: "voice_flow_step", @@ -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. @@ -16831,6 +16962,8 @@ async function createRescheduleFallback(params: { matchedAppointmentChoices?: string[]; reason?: string; requestedNewTime?: string; + kind?: "reschedule" | "cancellation"; + originalUtterance?: string; }) { const { callSid, @@ -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}` : "", @@ -16876,10 +17015,15 @@ 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({ @@ -16887,12 +17031,14 @@ async function createRescheduleFallback(params: { 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"), }, }); @@ -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, }); diff --git a/api/tests/voiceRescheduleLane.test.ts b/api/tests/voiceRescheduleLane.test.ts index 48b2d14..6bc0e3f 100644 --- a/api/tests/voiceRescheduleLane.test.ts +++ b/api/tests/voiceRescheduleLane.test.ts @@ -19,7 +19,7 @@ async function cleanupRescheduleFixtures() { where: { source: "voice", phone: { in: TEST_PHONES }, - requestType: "reschedule", + requestType: { in: ["reschedule", "cancellation"] }, }, }); await prisma.operationalEvent.deleteMany({ @@ -57,6 +57,31 @@ async function postIntentWithDigit(callSid: string, digit: string, from: string) }); } + +async function postIntentWithSpeech(callSid: string, speech: string, from: string, digit: string = "3") { + return request(app) + .post("/api/voice/intent?stage=intent&attempt=0") + .type("form") + .send({ + CallSid: callSid, + From: from, + Digits: digit, + SpeechResult: speech, + }); +} + +async function postCancellationLookup(callSid: string, from: string, speech: string = "I need to cancel my appointment") { + return request(app) + .post("/api/voice/reschedule?stage=cancel_lookup") + .type("form") + .send({ + CallSid: callSid, + From: from, + SpeechResult: speech, + Digits: "", + }); +} + async function postRescheduleStart(callSid: string, from: string, speech: string = "") { return request(app) .post("/api/voice/reschedule?stage=start") @@ -150,6 +175,18 @@ async function readLatestRescheduleCallback(phone: string) { }); } + +async function readLatestCancellationCallback(phone: string) { + return prisma.callbackRequest.findFirst({ + where: { + phone, + requestType: "cancellation", + source: "voice", + }, + orderBy: { createdAt: "desc" }, + }); +} + async function hasRescheduleEvent(callSid: string, eventName: string) { const event = await prisma.operationalEvent.findFirst({ where: { @@ -207,6 +244,44 @@ describe("voice hidden reschedule lane", () => { expect(res.text).toContain("Your appointment is not moved yet; staff will confirm the change."); }); + + it("Press 3 cancellation request creates cancellation callback without new-time prompt", async () => { + const callSid = `${CALLSID_PREFIX}PRESS_3_CANCEL`; + const from = TEST_PHONES[0]; + + await postInbound(callSid, from); + const routeRes = await postIntentWithSpeech( + callSid, + "I need to cancel my appointment", + from, + "3" + ); + + expect(routeRes.status).toBe(200); + expect(routeRes.text).toContain("/api/voice/reschedule?stage=cancel_lookup"); + expect(routeRes.text).toContain("Your appointment is not canceled yet"); + expect(routeRes.text).not.toContain("preferred new day"); + expect(routeRes.text).not.toContain("preferred new time"); + expect(routeRes.text).not.toContain("What preferred new day or time"); + + const cancelRes = await postCancellationLookup(callSid, from); + expect(cancelRes.status).toBe(200); + expect(cancelRes.text).toContain("cancellation request"); + expect(cancelRes.text).toContain("Your appointment is not canceled yet"); + expect(cancelRes.text).not.toContain("preferred new day"); + expect(cancelRes.text).not.toContain("preferred new time"); + + const callback = await readLatestCancellationCallback(from); + expect(callback).toBeDefined(); + expect(callback?.requestType).toBe("cancellation"); + expect(callback?.scenarioType).toBe("cancellation"); + expect(callback?.nonMedicalReason).toBe("appointment_cancellation_request"); + expect(callback?.summary).toContain("Cancellation request via hidden IVR lane."); + expect(callback?.summary).toContain("staff_action_required=confirm_cancellation"); + expect(callback?.summary).toContain("original_utterance=I need to cancel my appointment"); + expect(callback?.summary).not.toContain("staff_action_required=confirm_reschedule"); + }); + it("Press 4 Gather action posts to /api/voice/reschedule?stage=start", async () => { const callSid = `${CALLSID_PREFIX}GATHER_ACTION`; const from = TEST_PHONES[0]; diff --git a/ivr routing system.txt b/ivr routing system.txt index 0f756ab..d70bdcb 100644 --- a/ivr routing system.txt +++ b/ivr routing system.txt @@ -101,3 +101,6 @@ Do not claim: ## Operating Summary Press 1 is the public booking/payment lane, now including the provisional new-patient veteran callback/event-intake foundation after full-pay and deposit decline. Press 2 remains annual state/MMU help. Press 3 remains general/staff handoff and a future after-hours triage candidate. Rescheduling is hidden from the main menu and remains a staff-approved request lane by default. + + +Cancellation handling: explicit cancellation requests are not reschedule requests. If a caller asks to cancel an appointment through Press 3/general questions, route to a hidden staff-controlled cancellation request. Do not ask for a preferred new day or time, do not say the appointment is canceled, and do not mutate Acuity. Staff must confirm before anything is canceled. From db94bef71b5751dbb308d60e6c160563a443b39f Mon Sep 17 00:00:00 2001 From: CapuchaRojo Date: Fri, 8 May 2026 17:21:57 -0400 Subject: [PATCH 2/2] fix(voice): prioritize reschedule for mixed appointment-change intent --- api/src/routes/voice.ts | 64 +++++++++++++-------------- api/tests/voiceRescheduleLane.test.ts | 43 ++++++++++++++++++ 2 files changed, 75 insertions(+), 32 deletions(-) diff --git a/api/src/routes/voice.ts b/api/src/routes/voice.ts index 6a90018..15901f1 100644 --- a/api/src/routes/voice.ts +++ b/api/src/routes/voice.ts @@ -12619,26 +12619,26 @@ router.post("/voice/intent", async (req, res) => { // isLocationOrHours already defined above for Press 3 submenu - if (isCancellation) { + if (isReschedule) { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "option3_after_hours_cancellation_selected", + stage: "option3_after_hours_reschedule_selected", correlationId, - payload: { reason: "cancellation_inquiry" }, + payload: { reason: "reschedule_inquiry" }, }); - return respondWithCancellationLaneStart("intent"); + return respondWithRescheduleLaneStart("intent"); } - if (isReschedule) { + if (isCancellation && !isReschedule) { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "option3_after_hours_reschedule_selected", + stage: "option3_after_hours_cancellation_selected", correlationId, - payload: { reason: "reschedule_inquiry" }, + payload: { reason: "cancellation_inquiry" }, }); - return respondWithRescheduleLaneStart("intent"); + return respondWithCancellationLaneStart("intent"); } if (isBooking) { @@ -12906,30 +12906,30 @@ router.post("/voice/intent", async (req, res) => { } - if (digitChoice === "3" && utterance && hasExplicitCancellationIntent(utterance)) { + if (digitChoice === "3" && utterance && hasExplicitRescheduleIntent(utterance)) { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "press_3_cancellation_request_detected", + stage: "press_3_reschedule_request_detected", correlationId, payload: { - route: "hidden_cancellation_lane", - source: "press_three", + route: "hidden_reschedule_lane", ...buildRescheduleAssistantMetadata(), }, }); - return respondWithCancellationLaneStart("intent"); + return respondWithRescheduleLaneStart("intent"); } - if (digitChoice === "3" && utterance && hasExplicitRescheduleIntent(utterance)) { + if (utterance && hasExplicitRescheduleIntent(utterance)) { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "press_3_reschedule_request_detected", + stage: "general_info_reschedule_request_detected", correlationId, payload: { route: "hidden_reschedule_lane", + source: digitChoice === "3" ? "press_three" : "speech", ...buildRescheduleAssistantMetadata(), }, }); @@ -12937,6 +12937,22 @@ router.post("/voice/intent", async (req, res) => { 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( @@ -13052,7 +13068,7 @@ router.post("/voice/intent", async (req, res) => { } - if (utterance && hasExplicitCancellationIntent(utterance)) { + if (utterance && hasExplicitCancellationIntent(utterance) && !hasExplicitRescheduleIntent(utterance)) { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, @@ -13068,22 +13084,6 @@ router.post("/voice/intent", async (req, res) => { return respondWithCancellationLaneStart("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"); - } - const dtmfGeneralInfoTopicKey: GeneralInfoTopicKey | null = null; const resolvedGeneralInfoTopicKey = generalInfoTopicKey || dtmfGeneralInfoTopicKey; diff --git a/api/tests/voiceRescheduleLane.test.ts b/api/tests/voiceRescheduleLane.test.ts index 6bc0e3f..c9124d2 100644 --- a/api/tests/voiceRescheduleLane.test.ts +++ b/api/tests/voiceRescheduleLane.test.ts @@ -282,6 +282,49 @@ describe("voice hidden reschedule lane", () => { expect(callback?.summary).not.toContain("staff_action_required=confirm_reschedule"); }); + + it("Press 3 mixed cancel and reschedule request routes to hidden reschedule intake", async () => { + const rescheduleSpy = jest.spyOn(MockSchedulingProvider.prototype, "rescheduleAppointment"); + const callSid = `${CALLSID_PREFIX}PRESS_3_CANCEL_AND_RESCHEDULE`; + const from = TEST_PHONES[0]; + + await postInbound(callSid, from); + const res = await postIntentWithSpeech( + callSid, + "I need to cancel and reschedule my appointment", + from, + "3" + ); + + expect(res.status).toBe(200); + expect(res.text).toContain("/api/voice/reschedule?stage=start"); + expect(res.text).toContain("Your appointment is not moved yet; staff will confirm the change."); + expect(res.text).not.toContain("/api/voice/reschedule?stage=cancel_lookup"); + expect(res.text).not.toContain("Your appointment is not canceled yet"); + expect(rescheduleSpy).not.toHaveBeenCalled(); + }); + + it("speech mixed cancel and reschedule request routes to hidden reschedule intake", async () => { + const rescheduleSpy = jest.spyOn(MockSchedulingProvider.prototype, "rescheduleAppointment"); + const callSid = `${CALLSID_PREFIX}SPEECH_CANCEL_AND_RESCHEDULE`; + const from = TEST_PHONES[0]; + + await postInbound(callSid, from); + const res = await postIntentWithSpeech( + callSid, + "Can I cancel and reschedule my appointment?", + from, + "" + ); + + expect(res.status).toBe(200); + expect(res.text).toContain("/api/voice/reschedule?stage=start"); + expect(res.text).toContain("Your appointment is not moved yet; staff will confirm the change."); + expect(res.text).not.toContain("/api/voice/reschedule?stage=cancel_lookup"); + expect(res.text).not.toContain("Your appointment is not canceled yet"); + expect(rescheduleSpy).not.toHaveBeenCalled(); + }); + it("Press 4 Gather action posts to /api/voice/reschedule?stage=start", async () => { const callSid = `${CALLSID_PREFIX}GATHER_ACTION`; const from = TEST_PHONES[0];