From 5b838de91128808fbed6a5421793815e0927d64f Mon Sep 17 00:00:00 2001 From: CapuchaRojo Date: Fri, 8 May 2026 18:30:43 -0400 Subject: [PATCH 1/3] feat(voice): clarify Press 3 after-hours routing --- api/src/routes/voice.ts | 438 ++++++++++++++------- api/tests/voiceInboundReceptionist.test.ts | 163 ++++++++ 2 files changed, 463 insertions(+), 138 deletions(-) diff --git a/api/src/routes/voice.ts b/api/src/routes/voice.ts index 9613953..03483d5 100644 --- a/api/src/routes/voice.ts +++ b/api/src/routes/voice.ts @@ -382,6 +382,19 @@ type GeneralInfoTopicKey = | "pricing" | "what_to_bring" | "renewals"; +type Press3AfterHoursIntent = + | "medical_callback" + | "reschedule" + | "cancellation" + | "renewal_tele" + | "state_card" + | "new_patient" + | "veteran_callback" + | "hours_location" + | "pricing" + | "what_to_bring" + | "staff_callback" + | "unknown"; type OutboundTriageIntent = | "scheduling" | "renewal" @@ -1687,6 +1700,162 @@ function hasExplicitCancellationIntent(input: string) { ].some((pattern) => pattern.test(normalized)); } +function hasPress3AfterHoursMedicalQuestion(input: string) { + const normalized = String(input || "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return false; + + const administrativeRenewal = + hasAnnualStateCardRenewalIntent(normalized) || + hasExplicitSevenMonthDoctorRenewalIntent(normalized) || + /\b(prescription|recommendation)\s+renew(?:al|ed|ing)?\b/.test(normalized) || + /\brenew(?:al|ed|ing)?\s+(my\s+)?(prescription|recommendation)\b/.test(normalized); + if (administrativeRenewal) return false; + + return [ + /\bmedical advice\b/, + /\bclinical\b/, + /\bsymptoms?\b/, + /\bdiagnos(?:e|is)\b/, + /\btreat(?:ment)?\b/, + /\bdos(?:e|age|ing)\b/, + /\bside effects?\b/, + /\bshould i (take|use|increase|decrease|stop)\b/, + /\b(can|should) i use\b/, + /\bdoctor question\b/, + /\bpain\b/, + /\bbleeding\b/, + /\bvomit(?:ing)?\b/, + /\bdiarrhea\b/, + /\bfever\b/, + /\bcough\b/, + /\brash\b/, + /\binjur(?:y|ed)\b/, + /\bwound\b/, + /\binfection\b/, + /\bseizure\b/, + /\btoxic\b/, + /\bpoison\b/, + /\bemergency\b/, + ].some((pattern) => pattern.test(normalized)); +} + +function hasPress3AfterHoursNewPatientIntent(input: string) { + const normalized = String(input || "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + if (!normalized || hasAnnualStateCardRenewalIntent(normalized)) return false; + + return ( + parseSchedulingCaptureInput(normalized, "").bookingIntent === "new_card" || + /\b(first[- ]?time|new)\s+(patient|card)\b/.test(normalized) || + /\b(get|need|want|obtain|looking for|trying to get)\b.*\b(card|medical marijuana card)\b/.test(normalized) + ); +} + +function hasPress3AfterHoursStateCardIntent(input: string) { + const normalized = String(input || "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return false; + + return ( + hasAnnualStateCardRenewalIntent(normalized) || + /\bannual\b.*\b(card|mmu)\b/.test(normalized) || + /\b(state|florida|mmu)\s+card\b/.test(normalized) || + /\bmmu\b/.test(normalized) + ); +} + +function hasPress3AfterHoursVeteranIntent(input: string) { + const normalized = String(input || "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return false; + + return ( + /\bveterans? cannabis care\b/.test(normalized) || + /\bveterans?\b/.test(normalized) || + /\bi'?m a vet\b/.test(normalized) || + /\bi am a vet\b/.test(normalized) || + /\bmilitary\b/.test(normalized) || + /\bserved\b/.test(normalized) + ); +} + +function hasPress3AfterHoursWhatToBringIntent(input: string) { + const normalized = String(input || "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return false; + + return ( + /\bwhat (should|do) i bring\b/.test(normalized) || + /\bwhat to bring\b/.test(normalized) || + /\bpaperwork\b/.test(normalized) || + /\bforms?\b/.test(normalized) || + /\bmedical records?\b/.test(normalized) || + /\bdocuments?\b/.test(normalized) + ); +} + +function classifyPress3AfterHoursIntent( + speech: string, + digits: string +): Press3AfterHoursIntent { + const normalizedSpeech = String(speech || "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + const digit = String(digits || "").trim().charAt(0); + + if (hasPress3AfterHoursMedicalQuestion(normalizedSpeech)) return "medical_callback"; + if (hasExplicitRescheduleIntent(normalizedSpeech)) return "reschedule"; + if (hasExplicitCancellationIntent(normalizedSpeech)) return "cancellation"; + if ( + hasExplicitSevenMonthDoctorRenewalIntent(normalizedSpeech) || + /\b(prescription|recommendation)\s+renew(?:al|ed|ing)?\b/.test(normalizedSpeech) || + /\brenew(?:al|ed|ing)?\s+(my\s+)?(prescription|recommendation)\b/.test(normalizedSpeech) + ) { + return "renewal_tele"; + } + if (hasPress3AfterHoursStateCardIntent(normalizedSpeech)) return "state_card"; + if (hasPress3AfterHoursNewPatientIntent(normalizedSpeech) || digit === "1") { + return "new_patient"; + } + if (hasPress3AfterHoursVeteranIntent(normalizedSpeech)) return "veteran_callback"; + if ( + digit === "2" || + /\b(hours?|open|close|location|address|parking|where|located)\b/.test( + normalizedSpeech + ) + ) { + return "hours_location"; + } + if (/\b(pricing|price|cost|fee|fees|dollars?|\$)\b/.test(normalizedSpeech)) { + return "pricing"; + } + if (hasPress3AfterHoursWhatToBringIntent(normalizedSpeech)) { + return "what_to_bring"; + } + if ( + digit === "3" || + /\b(callback|staff|live|person|human|call back|operator)\b/.test( + normalizedSpeech + ) + ) { + return "staff_callback"; + } + + return "unknown"; +} + function classifyOutboundTriageInput( speech: string, digits: string @@ -12659,59 +12828,67 @@ router.post("/voice/intent", async (req, res) => { }, }); - const digit = digitChoice; - const normalizedSpeech = utterance ? utterance.toLowerCase() : ""; - - // Press 3 after-hours submenu: check location/hours BEFORE renewal - // because digit 2 means "hours/location" in this submenu, not renewal - const isLocationOrHoursDigit = digit === "2"; - const isLocationOrHoursSpeech = - normalizedSpeech.includes("hours") || - normalizedSpeech.includes("location") || - normalizedSpeech.includes("address") || - normalizedSpeech.includes("parking") || - normalizedSpeech.includes("where"); - const isLocationOrHours = isLocationOrHoursDigit || isLocationOrHoursSpeech; - - const isReschedule = hasExplicitRescheduleIntent(normalizedSpeech); - const isCancellation = hasExplicitCancellationIntent(normalizedSpeech); - - const isBooking = - digit === "1" || - (!isReschedule && normalizedSpeech.includes("book")) || - (!isReschedule && normalizedSpeech.includes("appointment")) || - (!isReschedule && normalizedSpeech.includes("schedule")) || - normalizedSpeech.includes("new patient") || - normalizedSpeech.includes("new card"); - - // Renewal in Press 3 submenu: speech only (digit 2 is hours/location) - const isRenewal = - normalizedSpeech.includes("renew") || - normalizedSpeech.includes("renewal") || - normalizedSpeech.includes("seven month") || - normalizedSpeech.includes("7 month"); - - const isCallbackOrStaff = - digit === "3" || - normalizedSpeech.includes("callback") || - normalizedSpeech.includes("staff") || - normalizedSpeech.includes("live") || - normalizedSpeech.includes("person") || - normalizedSpeech.includes("human") || - normalizedSpeech.includes("call back"); - - const isPricing = - normalizedSpeech.includes("pricing") || - normalizedSpeech.includes("price") || - normalizedSpeech.includes("cost") || - normalizedSpeech.includes("fee") || - normalizedSpeech.includes("$") || - normalizedSpeech.includes("dollar"); - - // isLocationOrHours already defined above for Press 3 submenu - - - if (isReschedule) { + const afterHoursIntent = classifyPress3AfterHoursIntent(speech, digitChoice); + const respondWithAfterHoursStaffCallback = async (preface?: string) => { + if (preface) { + await speakInboundWithElevenLabsBridgeOrFallback({ + req, + target: twiml, + text: preface, + context: { + callSid, + correlationId, + }, + }); + } + const plan = buildNamePlan({ + followupPreference: "staff_line", + callbackPreference: "staff_line", + readiness: "staff_requested", + readinessRaw: "after_hours_live_staff_request", + businessHoursStatus: "closed", + transferAttempted: "false", + transferResult: "not_attempted_after_hours", + }); + await applyPlan({ + req, + twiml, + plan, + context: { + callSid, + correlationId, + }, + }); + return res.status(200).type("text/xml").send(twiml.toString()); + }; + const appendAfterHoursFollowupGather = (prompt: string) => { + const gather = twiml.gather({ + input: ["speech", "dtmf"], + action: `/api/voice/intent?stage=option3_after_hours_offer&attempt=${attempt}&booking_lane=press_three`, + method: "POST", + speechTimeout: TWILIO_SPEECH_TIMEOUT, + actionOnEmptyResult: true, + }); + say(gather, prompt); + twiml.redirect( + `/api/voice/intent?stage=option3_after_hours_offer&attempt=${attempt + 1}&booking_lane=press_three` + ); + }; + + if (afterHoursIntent === "medical_callback") { + logVoiceOperationalStep({ + eventName: "voice_flow_step", + callSid, + stage: "option3_after_hours_medical_deferred", + correlationId, + payload: { reason: "medical_scope_deferred" }, + }); + return respondWithAfterHoursStaffCallback( + "I cannot provide medical advice, but staff or the doctor can follow up during business hours." + ); + } + + if (afterHoursIntent === "reschedule") { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, @@ -12722,7 +12899,7 @@ router.post("/voice/intent", async (req, res) => { return respondWithRescheduleLaneStart("intent"); } - if (isCancellation && !isReschedule) { + if (afterHoursIntent === "cancellation") { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, @@ -12733,156 +12910,141 @@ router.post("/voice/intent", async (req, res) => { return respondWithCancellationLaneStart("intent"); } - if (isBooking) { + if (afterHoursIntent === "renewal_tele") { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "option3_after_hours_booking_selected", + stage: "option3_after_hours_renewal_tele_selected", correlationId, - payload: { reason: "booking_selected" }, + payload: { reason: "seven_month_renewal_selected" }, }); - return respondWithBookingLaneStart("intent"); + return await respondWithInlineRenewalCaptureStart("intent"); } - if (isLocationOrHours) { + if (afterHoursIntent === "state_card") { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "option3_after_hours_location_hours_selected", + stage: "option3_after_hours_state_card_selected", correlationId, - payload: { reason: "location_hours_selected" }, + payload: { reason: "annual_state_card_selected" }, }); - await speakInboundWithElevenLabsBridgeOrFallback({ - req, - target: twiml, - text: "The office is located at 909 FL-436, Casselberry, Florida 32707. The phone number is 1-800-803-8525. Hours are Monday through Friday, 10 AM to 6 PM, with select Saturdays from 10 AM to 4 PM.", - context: { - callSid, - correlationId, - }, + return respondWithAnnualStateCardRenewalStart("intent"); + } + + if (afterHoursIntent === "new_patient") { + logVoiceOperationalStep({ + eventName: "voice_flow_step", + callSid, + stage: "option3_after_hours_booking_selected", + correlationId, + payload: { reason: "new_patient_selected" }, }); - const gather = twiml.gather({ - input: ["speech", "dtmf"], - action: `/api/voice/intent?stage=option3_after_hours_offer&attempt=${attempt}&booking_lane=press_three`, - method: "POST", - speechTimeout: TWILIO_SPEECH_TIMEOUT, - actionOnEmptyResult: true, + return respondWithBookingLaneStart("intent", { + ...parseSchedulingCaptureInput(speech, digitChoice), + bookingIntent: "new_card", + offerKey: "new_patient_full", }); - say(gather, "Press 1 for scheduling, 2 for hours or location, 3 for a callback request, or say the topic."); - twiml.redirect( - `/api/voice/intent?stage=option3_after_hours_offer&attempt=${attempt + 1}&booking_lane=press_three` - ); - return res.status(200).type("text/xml").send(twiml.toString()); } - if (isRenewal) { + if (afterHoursIntent === "veteran_callback") { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "option3_after_hours_renewal_selected", + stage: "option3_after_hours_veteran_deferred", correlationId, - payload: { reason: "renewal_selected" }, + payload: { reason: "veteran_staff_callback" }, }); - return respondWithAnnualStateCardRenewalStart("intent"); + return respondWithAfterHoursStaffCallback( + "Staff can confirm the correct Veterans Cannabis Care option, eligibility, and timing during business hours." + ); } - if (isCallbackOrStaff) { + if (afterHoursIntent === "hours_location") { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "option3_after_hours_callback_selected", + stage: "option3_after_hours_location_hours_selected", correlationId, - payload: { reason: "after_hours_live_staff_request" }, - }); - const plan = buildNamePlan({ - followupPreference: "staff_line", - callbackPreference: "staff_line", - readiness: "staff_requested", - readinessRaw: "after_hours_live_staff_request", - businessHoursStatus: "closed", - transferAttempted: "false", - transferResult: "not_attempted_after_hours", + payload: { reason: "location_hours_selected" }, }); - await applyPlan({ + await speakInboundWithElevenLabsBridgeOrFallback({ req, - twiml, - plan, + target: twiml, + text: "The office is located at 909 FL-436, Casselberry, Florida 32707. The phone number is 1-800-803-8525. Hours are Monday through Friday, 10 AM to 6 PM, with select Saturdays from 10 AM to 4 PM.", context: { callSid, correlationId, }, }); + appendAfterHoursFollowupGather("Press 1 for scheduling, 2 for hours or location, 3 for a callback request, or say the topic."); return res.status(200).type("text/xml").send(twiml.toString()); } - // After-hours scope guard: medical/unclear inputs create callback - const isMedicalOrUnclear = - normalizedSpeech.includes("doctor") || - normalizedSpeech.includes("medical") || - normalizedSpeech.includes("symptom") || - normalizedSpeech.includes("prescription") || - normalizedSpeech.includes("diagnosis") || - normalizedSpeech.includes("treatment") || - (!digit && !normalizedSpeech); - - if (isMedicalOrUnclear) { + if (afterHoursIntent === "pricing") { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "option3_after_hours_medical_deferred", + stage: "option3_after_hours_pricing_selected", correlationId, - payload: { reason: "medical_scope_deferred" }, - }); - const plan = buildNamePlan({ - followupPreference: "staff_line", - callbackPreference: "staff_line", - readiness: "staff_requested", - readinessRaw: "after_hours_live_staff_request", - businessHoursStatus: "closed", - transferAttempted: "false", - transferResult: "not_attempted_after_hours", + payload: { reason: "pricing_inquiry" }, }); - await applyPlan({ + await speakInboundWithElevenLabsBridgeOrFallback({ req, - twiml, - plan, + target: twiml, + text: "For new patients, the paid in full path is $199 plus a 4 percent transaction fee, with a displayed total of $206.96. There is also a $50 deposit plus a 4 percent transaction fee to secure the appointment. The separate $75 state fee is paid after doctor qualification. I can help you book or queue a callback for staff. Which would you prefer? Press 1 to book, press 3 for a callback.", context: { callSid, correlationId, }, }); + appendAfterHoursFollowupGather("Press 1 to book, press 3 for a callback."); return res.status(200).type("text/xml").send(twiml.toString()); } - if (isPricing) { + if (afterHoursIntent === "what_to_bring") { logVoiceOperationalStep({ eventName: "voice_flow_step", callSid, - stage: "option3_after_hours_pricing_selected", + stage: "option3_after_hours_what_to_bring_selected", correlationId, - payload: { reason: "pricing_inquiry" }, + payload: { reason: "what_to_bring_inquiry" }, }); await speakInboundWithElevenLabsBridgeOrFallback({ req, target: twiml, - text: "For new patients, the paid in full path is $199 plus a 4 percent transaction fee, with a displayed total of $206.96. There is also a $50 deposit plus a 4 percent transaction fee today to secure the appointment. The separate $75 state fee is paid after doctor qualification. I can help you book or queue a callback for staff. Which would you prefer? Press 1 to book, press 3 for a callback.", + text: "Bring your valid Florida ID. Medical records can be helpful if you have them, but staff can confirm what is needed for your situation.", context: { callSid, correlationId, }, }); - const gather = twiml.gather({ - input: ["speech", "dtmf"], - action: `/api/voice/intent?stage=option3_after_hours_offer&attempt=${attempt}&booking_lane=press_three`, - method: "POST", - speechTimeout: TWILIO_SPEECH_TIMEOUT, - actionOnEmptyResult: true, + appendAfterHoursFollowupGather("Press 1 for scheduling, 2 for hours or location, 3 for a callback request, or say the topic."); + return res.status(200).type("text/xml").send(twiml.toString()); + } + + if (afterHoursIntent === "staff_callback") { + logVoiceOperationalStep({ + eventName: "voice_flow_step", + callSid, + stage: "option3_after_hours_callback_selected", + correlationId, + payload: { reason: "after_hours_live_staff_request" }, }); - say(gather, "Press 1 to book, press 3 for a callback."); - twiml.redirect( - `/api/voice/intent?stage=option3_after_hours_offer&attempt=${attempt + 1}&booking_lane=press_three` + return respondWithAfterHoursStaffCallback(); + } + + if (afterHoursIntent === "unknown") { + logVoiceOperationalStep({ + eventName: "voice_flow_step", + callSid, + stage: "option3_after_hours_unknown_deferred", + correlationId, + payload: { reason: "unknown_or_low_confidence" }, + }); + return respondWithAfterHoursStaffCallback( + "I can queue a callback for normal business hours so staff can help with that." ); - return res.status(200).type("text/xml").send(twiml.toString()); } const maxReprompts = 2; diff --git a/api/tests/voiceInboundReceptionist.test.ts b/api/tests/voiceInboundReceptionist.test.ts index e8cd59b..49f8c0a 100644 --- a/api/tests/voiceInboundReceptionist.test.ts +++ b/api/tests/voiceInboundReceptionist.test.ts @@ -129,6 +129,30 @@ async function postTopLevelIntent(params: { }); } +async function postPress3AfterHoursOffer(params: { + callSid: string; + from: string; + speech?: string; + digits?: string; + attempt?: string; +}) { + return request(app) + .post("/api/voice/intent") + .type("form") + .send({ + CallSid: params.callSid, + SpeechResult: params.speech || "", + Digits: params.digits || "", + From: params.from, + To: "+18005550199", + }) + .query({ + stage: "option3_after_hours_offer", + attempt: params.attempt || "0", + booking_lane: "press_three", + }); +} + async function postStateCardName(params: { callSid: string; from: string; @@ -629,6 +653,145 @@ describe("voice inbound receptionist", () => { expect(res.text).toContain("after_hours_live_staff_request"); }); + it("option3_after_hours_offer routes seven-month renewal to Renewal TELE, not state-card", async () => { + const res = await postPress3AfterHoursOffer({ + callSid: `${CALLSID_PREFIX}OPTION3_SEVEN_MONTH_RENEWAL`, + from: TEST_PHONES[0], + speech: "I need my seven-month renewal", + }); + + expect(res.status).toBe(200); + expect(res.text).toContain("booking_intent=renew_by_phone"); + expect(res.text).toContain("offer_key=renewal_tele"); + expect(res.text).not.toContain("/api/voice/state-card-renewal/name"); + }); + + it("option3_after_hours_offer routes annual state-card renewal to Press 2 state-card lane", async () => { + const res = await postPress3AfterHoursOffer({ + callSid: `${CALLSID_PREFIX}OPTION3_ANNUAL_STATE_CARD`, + from: TEST_PHONES[0], + speech: "I need my annual state card renewed", + }); + + expect(res.status).toBe(200); + expect(res.text).toContain("/api/voice/state-card-renewal/name"); + expect(res.text).toContain("annual Florida MMU card renewal"); + expect(res.text).not.toContain("booking_intent=renew_by_phone"); + }); + + it("option3_after_hours_offer routes new-patient card requests to the Press 1 new-patient lane", async () => { + const res = await postPress3AfterHoursOffer({ + callSid: `${CALLSID_PREFIX}OPTION3_NEW_PATIENT_CARD`, + from: TEST_PHONES[0], + speech: "I'm a new patient and need a card", + }); + + expect(res.status).toBe(200); + expect(res.text).toContain("scheduling_submit"); + expect(res.text).toContain("booking_intent=new_card"); + expect(res.text).toContain("offer_key=new_patient_full"); + expect(res.text).not.toContain("/api/voice/state-card-renewal/name"); + }); + + it("option3_after_hours_offer routes veteran inquiries to safe staff callback handling", async () => { + const res = await postPress3AfterHoursOffer({ + callSid: `${CALLSID_PREFIX}OPTION3_VETERAN_CALLBACK`, + from: TEST_PHONES[0], + speech: "I'm a veteran", + }); + + expect(res.status).toBe(200); + expect(res.text).toContain("Veterans Cannabis Care option"); + expect(res.text).toContain("staff_line"); + expect(res.text).toContain("after_hours_live_staff_request"); + expect(res.text).not.toContain(" { + const res = await postPress3AfterHoursOffer({ + callSid: `${CALLSID_PREFIX}OPTION3_WHAT_TO_BRING`, + from: TEST_PHONES[0], + speech: "what should I bring", + }); + + expect(res.status).toBe(200); + expect(res.text).toContain("Bring your valid Florida ID."); + expect(res.text).toContain( + "Medical records can be helpful if you have them, but staff can confirm what is needed for your situation." + ); + expect(res.text).not.toContain(" { + const res = await postPress3AfterHoursOffer({ + callSid: `${CALLSID_PREFIX}OPTION3_PRICING_NO_PAY`, + from: TEST_PHONES[0], + speech: "How much does it cost?", + }); + + expect(res.status).toBe(200); + expect(res.text).toContain("paid in full path is $199"); + expect(res.text).not.toContain(" { + const res = await postPress3AfterHoursOffer({ + callSid: `${CALLSID_PREFIX}OPTION3_MEDICAL_CALLBACK`, + from: TEST_PHONES[0], + speech: "Should I increase my dose for pain?", + }); + + expect(res.status).toBe(200); + expect(res.text).toContain("cannot provide medical advice"); + expect(res.text).toContain("staff or the doctor can follow up"); + expect(res.text).toContain("after_hours_live_staff_request"); + expect(res.text).not.toContain("