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:
ff
2026-08-06 09:35:48 -04:00
co-authored by Claude Sonnet 5
parent 55ce52f276
commit 0cd095330a
7 changed files with 167 additions and 34 deletions
+104 -11
View File
@@ -12,7 +12,7 @@ import {
getOfficeHoursDisplay,
timeLabel,
} from "../ai/reschedule-graph";
import { getLlm, resolveAiProvider } from "../ai/llm-factory";
import { getLlm, resolveAiProvider, type AiProvider } from "../ai/llm-factory";
import { soundsLikeGoodbye } from "../ai/voice-assistant";
import { getPublicBaseUrl } from "../utils/publicUrl";
import {
@@ -116,6 +116,64 @@ const NEW_OR_RESCHEDULE_Q: Record<string, string> = {
"Haitian Creole": "Jis pou konfime — èske ou ta renmen pran yon nouvo randevou, oswa èske ou ta renmen reprogramè randevou aktyèl ou?",
};
// ── Stale-conversation handling (SMS) ─────────────────────────────────────────
// Unlike a phone call, a long gap between texts doesn't necessarily mean the
// patient walked away — replies are often just slow. So instead of hard
// resetting on any idle gap, we only second-guess the stored stage once the
// gap crosses STALE_SMS_THRESHOLD_MS, and even then we ask the LLM to look at
// the recent history before deciding whether the new text is still answering
// the last question or reads like a fresh, unrelated conversation.
const STALE_SMS_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours
const STILL_HERE_MSGS: Record<string, string> = {
English: "It's been a while since our last message. How can I help you today?",
Spanish: "Ha pasado un tiempo desde nuestro último mensaje. ¿Cómo puedo ayudarle hoy?",
Portuguese: "Já faz um tempo desde nossa última mensagem. Como posso ajudá-lo hoje?",
Mandarin: "距离我们上次的消息已经有一段时间了。今天我能帮您什么?",
Cantonese: "距離我們上次嘅消息已經有一段時間喇。今日我可以點幫您?",
Arabic: "لقد مر بعض الوقت منذ آخر رسالة لنا. كيف يمكنني مساعدتك اليوم؟",
"Haitian Creole": "Sa gen yon ti tan depi dènye mesaj nou. Kijan mwen ka ede ou jodi a?",
};
/**
* After a long idle gap, ask the LLM whether the patient's new text still
* answers what was last asked (recent history + stage in context) or reads
* like an unrelated, fresh message. Defaults to "continue" on any failure —
* an LLM call error shouldn't be the reason a legitimate reply gets bounced.
*/
async function isLikelyContinuation(
recentHistory: { role: "user" | "assistant"; text: string }[],
newMessage: string,
stage: ConversationStage,
apiKey: string,
provider: AiProvider,
model?: string,
): Promise<boolean> {
if (recentHistory.length === 0) return true;
try {
const llm = getLlm(provider, apiKey, model);
const transcript = recentHistory
.map(m => `${m.role === "assistant" ? "Office" : "Patient"}: ${m.text}`)
.join("\n");
const res = await llm.invoke([
{
role: "system",
content:
`You are helping a dental office text-messaging assistant resume a conversation with a patient after a long gap of silence. ` +
`Here is the recent conversation (oldest first):\n${transcript}\n\n` +
`The assistant is currently waiting on stage "${stage}" of a booking flow. ` +
`The patient just sent a new message after the long gap: "${newMessage}"\n\n` +
`Does this new message still look like it's answering/continuing that same conversation, or does it read like an unrelated, fresh message (e.g. a new question, a greeting, a different topic)? ` +
`Reply with ONLY one word: "continue" or "restart".`,
},
]);
return !/restart/i.test(String(res.content));
} catch {
return true;
}
}
// ── POST /api/twilio/webhook/sms ──────────────────────────────────────────────
const CONVO_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -427,16 +485,33 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
let stage = await getStage(patient.userId, patient.id);
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
// Reset conversation if idle for more than 5 minutes
// Long idle gap: don't blindly resume (SMS replies are often just slow),
// but don't blindly reset either — check whether the new text still reads
// like a continuation before deciding.
if (stage !== "initial" && stage !== "done") {
const convRow = await db.patientConversation.findUnique({
where: { patientId: patient.id },
select: { updatedAt: true },
});
if (convRow?.updatedAt && Date.now() - convRow.updatedAt.getTime() > CONVO_TIMEOUT_MS) {
clearPendingReschedule(patient.userId, patient.id);
await setStage(patient.userId, patient.id, "initial");
stage = "initial";
if (convRow?.updatedAt && Date.now() - convRow.updatedAt.getTime() > STALE_SMS_THRESHOLD_MS) {
const recentComms = await storage.getCommunicationsByPatient(patient.id);
const recentHistory = recentComms.slice(-5).map((c: any) => ({
role: (c.direction === "outbound" ? "assistant" : "user") as "assistant" | "user",
text: c.body || "",
}));
const continuing = await isLikelyContinuation(
recentHistory, Body, stage, activeAi.key, activeAi.provider, activeAi.model
);
if (!continuing) {
clearPendingReschedule(patient.userId, patient.id);
await setStage(patient.userId, patient.id, "initial");
stage = "initial";
const stillHereText = STILL_HERE_MSGS[language] ?? STILL_HERE_MSGS["English"]!;
await saveOutbound(patient.id, stillHereText);
res.set("Content-Type", "text/xml");
return res.send(twimlMessages(stillHereText));
}
// Looks like a continuation — fall through and resume the stored stage.
}
}
const officeContact = await storage.getOfficeContact(patient.userId);
@@ -690,7 +765,7 @@ function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLoc
// silence timeout — nothing after </Gather> would ever run.
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Gather input="speech" language="${locale.gatherLang}" speechTimeout="auto" action="${gatherActionUrl}" method="POST">
<Gather input="speech" language="${locale.gatherLang}" speechTimeout="auto" timeout="15" action="${gatherActionUrl}" method="POST">
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
</Gather>
</Response>`;
@@ -741,10 +816,16 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
// A "recall" call (staff-triggered from Patient Connection, offering an
// existing patient a routine exam & cleaning) seeds a different stage
// than the default reminder-style check-in.
const currentStage = await getStage(patient.userId, patient.id);
if (currentStage === "initial" || currentStage === "done") {
if (isRecallCall) await startRecallConversation(patient.userId, patient.id);
else await resetConversation(patient.userId, patient.id);
// Either kind of call always speaks its own pitch fresh (see greeting
// below), so it must always reset the stage — even if a prior call of
// either kind left the patient stuck mid-flow (e.g. "recall_asked_datetime"
// or "asked_reschedule_datetime"). Otherwise the reply below gets
// interpreted against that stale leftover stage instead of the fresh
// pitch that was just spoken.
if (isRecallCall) {
await startRecallConversation(patient.userId, patient.id);
} else {
await resetConversation(patient.userId, patient.id);
}
const officeContact = await storage.getOfficeContact(patient.userId);
@@ -779,8 +860,12 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
const locale = voiceLocaleFor(session.language);
// ── Caller stayed silent past the speech timeout ──────────────────────
// A call is a single continuous conversation, unlike SMS — if it ends
// here, mid-flow or not, there's nothing left to resume, so reset the
// stage now instead of leaving the patient stuck for their next call/text.
if (!SpeechResult?.trim()) {
aiCallSessions.delete(CallSid);
await setStage(session.userId, session.patientId, "done");
return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye.", locale));
}
@@ -795,6 +880,7 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
// ── Caller said something that sounds like goodbye, or we've hit the cap ──
if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) {
aiCallSessions.delete(CallSid);
await setStage(session.userId, session.patientId, "done");
return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.", locale));
}
@@ -802,6 +888,7 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
aiCallSessions.delete(CallSid);
await setStage(session.userId, session.patientId, "done");
return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.", locale));
}
@@ -826,6 +913,12 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
if (routed.nextStage === "done" || soundsLikeGoodbye(reply)) {
aiCallSessions.delete(CallSid);
// routePatientTurn() already persisted nextStage — but if we're hanging
// up because the reply sounded like a goodbye while nextStage was still
// mid-flow, force it to "done" so the next call/text doesn't resume there.
if (routed.nextStage !== "done") {
await setStage(session.userId, session.patientId, "done");
}
return res.send(aiVoiceHangup(reply, locale));
}