feat: add AI-driven phone calls to patient connection

Adds an "AI Call" option alongside Call/SMS/Chat that places an outbound
call where Lisa converses live with the patient via Twilio speech
gather + TTS, using the same conversational AI as chat. Includes a
separate, user-editable Call Template (Settings > AI Chat/Call Settings)
so the call greeting can be customized independently of SMS templates.

Also fixes the outbound-call webhook URL to always use the fixed public
Twilio hostname (CLOUDFLARE_HOST) instead of deriving it from the
triggering request, since staff-browser requests arrive over the
LAN-only hostname, which Twilio can never reach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ff
2026-07-31 20:14:09 -04:00
co-authored by Claude Sonnet 5
parent ed5a8af835
commit 089e94ba88
9 changed files with 364 additions and 6 deletions
+65
View File
@@ -0,0 +1,65 @@
import { getLlm, type AiProvider } from "./llm-factory";
export interface VoiceTurn {
role: "assistant" | "user";
text: string;
}
export interface VoiceAssistantContext {
firstName: string;
officeName: string;
officeAddress: string;
officePhone: string;
appointmentDatetime: string;
}
const CLOSING_PATTERNS =
/\b(bye|goodbye|good bye|that'?s all|nothing else|no thanks|no thank you|i'?m good|that'?s it|hang up)\b/i;
export function soundsLikeGoodbye(text: string): boolean {
return CLOSING_PATTERNS.test(text.trim());
}
/**
* Generate Lisa's next spoken reply given the call transcript so far.
* Falls back to a plain closing line if the LLM call fails, so a broken
* key never leaves the caller stuck mid-call.
*/
export async function runVoiceAssistantTurn(
history: VoiceTurn[],
ctx: VoiceAssistantContext,
apiKey: string,
provider: AiProvider = "google",
model?: string
): Promise<string> {
const fallback =
"I'm sorry, I'm having trouble right now — our staff will follow up with you shortly. Thank you for calling, goodbye.";
try {
const llm = getLlm(provider, apiKey, model);
const system = [
`You are Lisa, a friendly AI phone assistant for ${ctx.officeName || "a dental office"}.`,
`You are speaking live by phone with ${ctx.firstName || "the patient"}.`,
ctx.appointmentDatetime
? `Their next appointment is on ${ctx.appointmentDatetime}.`
: `They have no upcoming appointment on file.`,
ctx.officeAddress ? `Office address: ${ctx.officeAddress}.` : "",
ctx.officePhone ? `Office phone: ${ctx.officePhone}.` : "",
"You can confirm their appointment and answer general questions about the practice.",
"If they want to reschedule or need something you can't resolve yourself, tell them a staff member will call or text them to confirm — never claim you rebooked or changed anything yourself.",
"This reply will be read aloud by text-to-speech: keep it conversational and SHORT, 1-2 sentences, no formatting, no lists.",
"If they indicate they're done (goodbye, that's all, etc.), give a brief warm goodbye.",
].filter(Boolean).join(" ");
const messages = [
{ role: "system", content: system },
...history.map((t) => ({ role: t.role === "assistant" ? "assistant" : "user", content: t.text })),
];
const res = await llm.invoke(messages as any);
const text = String(res.content).trim();
return text || fallback;
} catch {
return fallback;
}
}