fix: AI recall-call flow, speech-to-text time parsing, and stale conversation state
- 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>
This commit is contained in:
@@ -2,7 +2,7 @@ import twilio from "twilio";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { storage } from "../storage";
|
||||
import { runReminderGraph } from "./reminder-graph";
|
||||
import { runRecallGraph, runRecallBookingStep } from "./recall-graph";
|
||||
import { runRecallGraph, runRecallBookingStep, hasDateOrTimeSignal } from "./recall-graph";
|
||||
import { runNewPatientStep } from "./new-patient-graph";
|
||||
import {
|
||||
runRescheduleStep,
|
||||
@@ -290,10 +290,16 @@ export async function routePatientTurn(params: {
|
||||
if (intent === "yes") {
|
||||
// Patient may have already given a date (or date + time) in this same
|
||||
// reply ("Tuesday 1pm") — try to book right away instead of asking again.
|
||||
const { reply: bookingReply, nextStage: bookingNextStage } = await runRecallBookingStep(
|
||||
message, "recall_asked_datetime", language, patient.id, patient.userId, activeAi.key, activeAi.provider, activeAi.model
|
||||
);
|
||||
return finish(patient.userId, patient.id, [bookingReply], bookingNextStage);
|
||||
// A bare "yes"/"ok" has nothing to parse, so skip straight to asking for
|
||||
// day + time instead of wasting a parse call that used to come back
|
||||
// empty and send a mismatched "Sorry, I didn't catch a day" reply.
|
||||
if (hasDateOrTimeSignal(message)) {
|
||||
const { reply: bookingReply, nextStage: bookingNextStage } = await runRecallBookingStep(
|
||||
message, "recall_asked_datetime", language, patient.id, patient.userId, activeAi.key, activeAi.provider, activeAi.model
|
||||
);
|
||||
return finish(patient.userId, patient.id, [bookingReply], bookingNextStage);
|
||||
}
|
||||
return finish(patient.userId, patient.id, [intentReply], "recall_asked_datetime");
|
||||
}
|
||||
if (intent === "no") {
|
||||
return finish(patient.userId, patient.id, [intentReply], "done");
|
||||
|
||||
@@ -23,6 +23,19 @@ type GraphStateType = typeof GraphState.State;
|
||||
|
||||
// ── Intent classifier — multilingual yes/no keywords (shared shape with reminder-graph) ──
|
||||
|
||||
/** True if `text` mentions a concrete day/date/time (e.g. "Tuesday", "3/10", "2pm").
|
||||
* Shared by classifyNode (a bare "yes" vs. "yes, Tuesday at 1pm" both count as
|
||||
* intent "yes") and by the caller in patient-conversation-router.ts, which uses
|
||||
* it to decide whether to attempt an immediate booking parse or just ask for a
|
||||
* day/time first. */
|
||||
export function hasDateOrTimeSignal(text: string): boolean {
|
||||
return (
|
||||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|today|tomorrow|next week)\b/i.test(text) ||
|
||||
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(text) ||
|
||||
/\b\d{1,2}(:\d{2})?\s*(am|pm|a\.m\.|p\.m\.)\b/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function classifyNode(state: GraphStateType) {
|
||||
const text = state.message.toLowerCase().trim();
|
||||
|
||||
@@ -32,10 +45,7 @@ function classifyNode(state: GraphStateType) {
|
||||
// A specific day/date/time, or an appointment-request phrase, is itself a
|
||||
// strong signal the patient wants to book — real replies rarely say a bare
|
||||
// "yes", they jump straight to "Tuesday at 1pm" or "I want an appointment".
|
||||
const mentionsDateOrTime =
|
||||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(text) ||
|
||||
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(text) ||
|
||||
/\b\d{1,2}(:\d{2})?\s*(am|pm|a\.m\.|p\.m\.)\b/i.test(text);
|
||||
const mentionsDateOrTime = hasDateOrTimeSignal(text);
|
||||
const mentionsAppointment = /\b(appointment|schedule|book|come in|check.?up|cleaning)\b/i.test(text);
|
||||
|
||||
if (noPatterns.test(text) && !mentionsDateOrTime) return { intent: "no" };
|
||||
@@ -52,13 +62,13 @@ function routeByIntent(state: GraphStateType): string {
|
||||
// ── Fallbacks ──────────────────────────────────────────────────────────────
|
||||
|
||||
const WANTS_BOOKING_FALLBACKS: Record<string, string> = {
|
||||
English: "Great! What day works best for you?",
|
||||
Spanish: "¡Genial! ¿Qué día le viene mejor?",
|
||||
Portuguese: "Ótimo! Qual dia é melhor para você?",
|
||||
Mandarin: "太好了!您哪天方便?",
|
||||
Cantonese: "太好喇!您邊日方便?",
|
||||
Arabic: "رائع! ما هو اليوم الأنسب لك؟",
|
||||
"Haitian Creole": "Bon! Ki jou ki pi bon pou ou?",
|
||||
English: "Great! What day and time work best for you?",
|
||||
Spanish: "¡Genial! ¿Qué día y hora le vienen mejor?",
|
||||
Portuguese: "Ótimo! Qual dia e horário são melhores para você?",
|
||||
Mandarin: "太好了!您哪天、几点方便?",
|
||||
Cantonese: "太好喇!您邊日、幾點方便?",
|
||||
Arabic: "رائع! ما هو اليوم والوقت الأنسب لك؟",
|
||||
"Haitian Creole": "Bon! Ki jou ak ki lè ki pi bon pou ou?",
|
||||
};
|
||||
|
||||
const DECLINES_FALLBACKS: Record<string, string> = {
|
||||
@@ -98,7 +108,7 @@ async function wantsBookingNode(state: GraphStateType, config: any) {
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
`You are a friendly dental office assistant. The patient agreed to come in for a routine exam and cleaning. Write a short, warm reply (1-2 sentences max) and ask what day works best for them. Do NOT introduce yourself or say your name — the introduction has already been sent in a separate message. You MUST reply in ${lang}. No formatting, no extra text.`,
|
||||
`You are a friendly dental office assistant. The patient agreed to come in for a routine exam and cleaning. Write a short, warm reply (1-2 sentences max) and ask what day AND time work best for them. Do NOT introduce yourself or say your name — the introduction has already been sent in a separate message. You MUST reply in ${lang}. No formatting, no extra text.`,
|
||||
},
|
||||
{ role: "user", content: `Patient replied: "${state.message}"` },
|
||||
]);
|
||||
|
||||
@@ -392,18 +392,35 @@ async function isSlotAvailable(
|
||||
// ── Time parsing (legacy) ─────────────────────────────────────────────────────
|
||||
|
||||
export async function parseTime(message: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string | null> {
|
||||
const t = message.toLowerCase();
|
||||
// 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})(?::(\d{2}))?\s*(am|pm)\b/);
|
||||
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 = ampm[2] ? parseInt(ampm[2]) : 0;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user