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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user