From 406ea179299838f8f6ae181f1925fdd2cc9ef7bd Mon Sep 17 00:00:00 2001 From: CapuchaRojo Date: Sat, 9 May 2026 15:48:53 -0400 Subject: [PATCH] fix(voice): improve veteran callback capture cue --- api/src/routes/voice.ts | 100 ++++++++++++++++++++++++-- api/tests/voicePaymentCapture.test.ts | 96 +++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/api/src/routes/voice.ts b/api/src/routes/voice.ts index fce6346..675c1e6 100644 --- a/api/src/routes/voice.ts +++ b/api/src/routes/voice.ts @@ -165,6 +165,12 @@ const MEDICAL_GUARDRAIL_REPLY = "I cannot provide medical advice, but I can connect you with staff or arrange a callback."; const NEW_PATIENT_VETERAN_CALLBACK_PROMPT = "Thank you for contacting Veterans Cannabis Care in Casselberry, Florida. This program provides 100% free medical marijuana cards for qualifying Florida resident Veterans. Please note: This program is for first-time patients only, who have never previously had a Florida medical marijuana card. Per State law, the initial appointment must be completed in person at our Casselberry clinic. Appointments are limited and scheduled based on availability. Due to high demand, all calls are returned in the order received. Please leave your full name, your phone number, and the best time to return your call. A member of our team will return your call as soon as possible. Thank you for your sacrifice."; +const NEW_PATIENT_VETERAN_CALLBACK_CAPTURE_CUE = + "You can say that now: your full name, phone number, and the best time for our team to call you back."; +const NEW_PATIENT_VETERAN_CALLBACK_REPROMPT = + "I didn't catch that. Please say your full name, phone number, and the best time for our team to call you back."; +const NEW_PATIENT_VETERAN_CALLBACK_MISSING_DETAILS_CONFIRMATION = + "I still didn't catch the callback details clearly. Staff will use the phone number from this call if available and follow up as soon as possible."; const NEW_PATIENT_VETERAN_PRIOR_CARD_PROMPT = "Since you may have had a card before, staff should confirm the correct path before we promise the free event or any paid option. What time would be best for a callback?"; const NEW_PATIENT_VETERAN_PRICE_PROMPT = @@ -510,6 +516,17 @@ const SCHEDULING_CAPTURE_PROMPT = const SCHEDULING_CAPTURE_SPEECH_TIMEOUT = String( process.env.TWILIO_SCHEDULING_SPEECH_TIMEOUT || "2" ).trim() || "2"; +const VETERAN_CALLBACK_CAPTURE_SPEECH_TIMEOUT = String( + process.env.TWILIO_VETERAN_CALLBACK_SPEECH_TIMEOUT || "auto" +).trim() || "auto"; +const RAW_VETERAN_CALLBACK_CAPTURE_TIMEOUT = Number( + process.env.TWILIO_VETERAN_CALLBACK_TIMEOUT || 10 +); +const VETERAN_CALLBACK_CAPTURE_TIMEOUT = + Number.isFinite(RAW_VETERAN_CALLBACK_CAPTURE_TIMEOUT) && + RAW_VETERAN_CALLBACK_CAPTURE_TIMEOUT >= 1 + ? Math.floor(RAW_VETERAN_CALLBACK_CAPTURE_TIMEOUT) + : 10; const MAX_VOICE_OFFERED_SLOTS = 4; const RAW_VOICE_SCHEDULING_CAPTURE_MAX_ATTEMPTS = Number( process.env.VOICE_SCHEDULING_CAPTURE_MAX_ATTEMPTS || 3 @@ -8373,6 +8390,7 @@ router.post("/voice/intent", async (req, res) => { bookingLane?: "press_one"; offeredSlots?: AvailableSlot[]; paymentMethodBookingFailures?: number; + queryParams?: Record; } ) => { const nextSchedulingAttempt = @@ -8467,6 +8485,11 @@ router.post("/voice/intent", async (req, res) => { const offeredSlotTimes = options.offeredSlots.map((slot) => slot.displayTime); query.set("offered_slots", offeredSlotTimes.join("|")); } + if (options?.queryParams) { + for (const [key, value] of Object.entries(options.queryParams)) { + if (key && value) query.set(key, value); + } + } const gatherAction = `/api/voice/intent?${query.toString()}`; logVoiceOperationalStep({ eventName: "voice_flow_step", @@ -8483,11 +8506,18 @@ router.post("/voice/intent", async (req, res) => { }, }); + const isVeteranCallbackCapture = + options?.capture?.scriptStep === "veteran_callback"; const gather = twiml.gather({ input: ["speech", "dtmf"], action: gatherAction, method: "POST", - speechTimeout: SCHEDULING_CAPTURE_SPEECH_TIMEOUT, + speechTimeout: isVeteranCallbackCapture + ? VETERAN_CALLBACK_CAPTURE_SPEECH_TIMEOUT + : SCHEDULING_CAPTURE_SPEECH_TIMEOUT, + ...(isVeteranCallbackCapture + ? { timeout: VETERAN_CALLBACK_CAPTURE_TIMEOUT } + : {}), actionOnEmptyResult: true, }); await speakInboundWithElevenLabsBridgeOrFallback({ @@ -8736,6 +8766,8 @@ router.post("/voice/intent", async (req, res) => { mapBookingIntentToRequestType(capture.bookingIntent) || "scheduling"; const bookingTimeWindow = buildSchedulingCaptureTimeWindow(capture); const callbackTimeWindow = String(capture.callbackTime || "").trim(); + const suppressDefaultTimeWindow = + options?.reason === "new_patient_veteran_callback_missing_details"; const publicSchedulerUrl = buildPublicSchedulerUrlForOffer( selection?.appointmentTypeId ); @@ -8744,7 +8776,10 @@ router.post("/voice/intent", async (req, res) => { name: capture.collectedName || "Voice caller", phone: draftPhone, requestType, - preferredTime: callbackTimeWindow || bookingTimeWindow || null, + preferredTime: + callbackTimeWindow || + (suppressDefaultTimeWindow ? "" : bookingTimeWindow) || + null, status: "pending", scenarioType: mapBookingIntentToScenarioType(capture.bookingIntent), scenarioState: "pending", @@ -8768,7 +8803,7 @@ router.post("/voice/intent", async (req, res) => { "availability_strategy=any_available_then_calendar_pool", capture.requestedDate ? `requested_date=${capture.requestedDate}` : "", capture.requestedTime ? `requested_time=${capture.requestedTime}` : "", - bookingTimeWindow ? `time_window=${bookingTimeWindow}` : "", + bookingTimeWindow && !suppressDefaultTimeWindow ? `time_window=${bookingTimeWindow}` : "", callbackTimeWindow ? `callback_window=${sanitizeVoiceSummaryToken(callbackTimeWindow, 100)}` : "", `availability_source=${source}`, options?.reason ? `fallback_reason=${options.reason}` : "", @@ -11250,7 +11285,7 @@ router.post("/voice/intent", async (req, res) => { if (isVeteran) { return respondWithSchedulingCapturePrompt("retry", { - prompt: NEW_PATIENT_VETERAN_CALLBACK_PROMPT, + prompt: `${NEW_PATIENT_VETERAN_CALLBACK_PROMPT} ${NEW_PATIENT_VETERAN_CALLBACK_CAPTURE_CUE}`, schedulingAttempt: schedulingAttempt + 1, capture: { ...capture, @@ -11947,6 +11982,63 @@ router.post("/voice/intent", async (req, res) => { // Veteran callback time capture. This is a staff-confirmed callback lane only: // no payment capture, no Acuity mutation, and no paid veteran pricing. + if (capture.scriptStep === "veteran_callback" && !utterance) { + const emptyAttemptParam = Number( + req.query?.veteran_callback_empty_attempt || 0 + ); + const emptyAttempt = + Number.isFinite(emptyAttemptParam) && emptyAttemptParam >= 0 + ? Math.floor(emptyAttemptParam) + : 0; + + if (emptyAttempt < 1) { + return respondWithSchedulingCapturePrompt("retry", { + prompt: NEW_PATIENT_VETERAN_CALLBACK_REPROMPT, + schedulingAttempt: schedulingAttempt + 1, + capture: { + ...capture, + isVeteran: capture.isVeteran ?? true, + productType: undefined, + scriptStep: "veteran_callback", + }, + queryParams: { + veteran_callback_empty_attempt: String(emptyAttempt + 1), + }, + }); + } + + return respondWithBookingDraftFallback( + "booking_request_required", + { + ...capture, + callbackTime: undefined, + requestedDate: undefined, + requestedTime: undefined, + timeWindowRaw: "", + timeWindowConfidence: false, + lookupPhone: capture.collectedPhone || capture.lookupPhone, + isVeteran: capture.isVeteran ?? true, + productType: undefined, + }, + { + schedulingAttempt: schedulingAttempt + 1, + lookupPhone: capture.collectedPhone || capture.lookupPhone, + reason: "new_patient_veteran_callback_missing_details", + collectedName: capture.collectedName, + collectedPhone: capture.collectedPhone, + collectedEmail: capture.collectedEmail, + confirmationPrompt: + NEW_PATIENT_VETERAN_CALLBACK_MISSING_DETAILS_CONFIRMATION, + summaryTokens: [ + "veteran_callback_details_captured=false", + "veteran_callback_missing_reason=silence", + `veteran_callback_empty_attempts=${emptyAttempt + 1}`, + "veteran_callback_phone_source=fallback", + ], + } + ); + } + if (capture.scriptStep === "veteran_callback" && utterance) { const veteranCallbackDetails = parseVeteranCallbackContactDetails(utterance); diff --git a/api/tests/voicePaymentCapture.test.ts b/api/tests/voicePaymentCapture.test.ts index 190911d..829a4f1 100644 --- a/api/tests/voicePaymentCapture.test.ts +++ b/api/tests/voicePaymentCapture.test.ts @@ -499,6 +499,9 @@ describe("voice PCI-safe payment capture", () => { expect(veteranYes.text).toContain("calls are returned in the order received"); expect(veteranYes.text).toContain("Please leave your full name, your phone number, and the best time to return your call"); expect(veteranYes.text).toContain("Thank you for your sacrifice"); + expect(veteranYes.text).toContain("You can say that now: your full name, phone number, and the best time for our team to call you back"); + expect(veteranYes.text).toContain('speechTimeout="auto"'); + expect(veteranYes.text).toContain('timeout="10"'); expect(veteranYes.text).not.toContain(" { expect(callbackTime.text).toContain("captured that veteran callback request"); expect(callbackTime.text).not.toContain(" { expect(callback?.summary || "").toContain("veteran_callback_phone_source=fallback"); }); + it("reprompts veteran callback silence without creating a completed callback", async () => { + const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_EMPTY_RETRY`; + const veteranCallbackQuery = newPatientPaymentQuery({ + script_step: "veteran_callback", + payment_option: "not_ready", + is_veteran: "true", + }); + + const silence = await request(app) + .post(`/api/voice/intent?${veteranCallbackQuery.toString()}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: "", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(silence.status).toBe(200); + expect(silence.text).toContain("I didn't catch that"); + expect(silence.text).toContain("Please say your full name, phone number, and the best time for our team to call you back"); + expect(silence.text).toContain('speechTimeout="auto"'); + expect(silence.text).toContain('timeout="10"'); + expect(silence.text).not.toContain("captured that veteran callback request"); + expect(silence.text).not.toContain(" { + const callSid = `${CALLSID_PREFIX}VETERAN_CALLBACK_EMPTY_FALLBACK`; + const veteranCallbackQuery = newPatientPaymentQuery({ + script_step: "veteran_callback", + payment_option: "not_ready", + is_veteran: "true", + veteran_callback_empty_attempt: "1", + }); + + const fallback = await request(app) + .post(`/api/voice/intent?${veteranCallbackQuery.toString()}`) + .set("X-VetCan-Tenant", "marijuanaexpressmd.astormscoming.com") + .type("form") + .send({ + CallSid: callSid, + SpeechResult: "", + Digits: "", + From: TEST_PHONES[0], + To: "+18005550199", + }); + + expect(fallback.status).toBe(200); + expect(fallback.text).toContain("I still didn't catch the callback details clearly"); + expect(fallback.text).toContain("Staff will use the phone number from this call if available"); + expect(fallback.text).not.toContain(" { const callSid = `${CALLSID_PREFIX}VETERAN_NO_CALLBACK`; const veteranQuery = newPatientPaymentQuery({