feat: unify AI voice calls with SMS conversation rules, add Call Templates

AI phone calls now route through the same routePatientTurn() rule engine
as SMS (office-hours checks, slot-conflict checks, MassHealth eligibility,
staff escalation) instead of a freeform LLM chat, and speak in the
patient's preferred language (English/Spanish). Adds a separate "Call
Templates" settings section so spoken wording can be customized
independently from SMS wording.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ff
2026-07-31 23:39:31 -04:00
co-authored by Claude Sonnet 5
parent 089e94ba88
commit 06dc63c53e
6 changed files with 973 additions and 726 deletions
@@ -18,7 +18,13 @@ type AiChatTemplates = {
reminderGreeting: string;
newPatientGreeting: string;
generalFallback: string;
callTemplate: string;
};
type AiCallTemplates = {
greeting: string;
reminderGreeting: string;
newPatientGreeting: string;
generalFallback: string;
};
type OfficeContact = {
@@ -37,8 +43,14 @@ const DEFAULTS = {
"Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?",
generalFallback:
"Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?",
callTemplate:
};
const CALL_DEFAULTS = {
greeting:
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?",
reminderGreeting: "Sure, I can help with that.",
newPatientGreeting: "No problem, let's get you set up.",
generalFallback: "Sure, how can I help?",
};
const DEFAULT_SMS_TEMPLATES = [
@@ -1012,9 +1024,14 @@ export function AiChatSettingsCard() {
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
const [callTemplate, setCallTemplate] = useState(DEFAULTS.callTemplate);
const initialized = useRef(false);
const [callGreeting, setCallGreeting] = useState(CALL_DEFAULTS.greeting);
const [callReminderGreeting, setCallReminderGreeting] = useState(CALL_DEFAULTS.reminderGreeting);
const [callNewPatientGreeting, setCallNewPatientGreeting] = useState(CALL_DEFAULTS.newPatientGreeting);
const [callGeneralFallback, setCallGeneralFallback] = useState(CALL_DEFAULTS.generalFallback);
const callInitialized = useRef(false);
const [openPhoneReply, setOpenPhoneReply] = useState(false);
const { data: advancedSettings } = useQuery<{ openPhoneReply: boolean }>({
@@ -1091,10 +1108,31 @@ export function AiChatSettingsCard() {
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
setCallTemplate(templates.callTemplate || DEFAULTS.callTemplate);
}
}, [templates]);
const { data: callTemplatesData, isLoading: callTemplatesLoading } = useQuery<AiCallTemplates>({
queryKey: ["/api/ai/call-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/call-templates");
if (!res.ok) throw new Error("Failed to load call templates");
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
// Seed AI call templates
useEffect(() => {
if (callTemplatesData && !callInitialized.current) {
callInitialized.current = true;
setCallGreeting(callTemplatesData.greeting || CALL_DEFAULTS.greeting);
setCallReminderGreeting(callTemplatesData.reminderGreeting || CALL_DEFAULTS.reminderGreeting);
setCallNewPatientGreeting(callTemplatesData.newPatientGreeting || CALL_DEFAULTS.newPatientGreeting);
setCallGeneralFallback(callTemplatesData.generalFallback || CALL_DEFAULTS.generalFallback);
}
}, [callTemplatesData]);
// Seed SMS template list — fall back to the saved reminderSms if list is empty
useEffect(() => {
if (smsTemplateListData && !smsInitialized.current) {
@@ -1125,6 +1163,24 @@ export function AiChatSettingsCard() {
},
});
const saveCallMutation = useMutation({
mutationFn: async (data: AiCallTemplates) => {
const res = await apiRequest("PUT", "/api/ai/call-templates", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save call templates");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/call-templates"] });
toast({ title: "Templates saved", description: "AI call templates have been updated." });
},
onError: (err: any) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const saveSmsListMutation = useMutation({
mutationFn: async (list: SmsTemplate[]) => {
const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list);
@@ -1149,7 +1205,16 @@ export function AiChatSettingsCard() {
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
callTemplate: callTemplate.trim() || DEFAULTS.callTemplate,
});
};
const handleCallSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveCallMutation.mutate({
greeting: callGreeting.trim() || CALL_DEFAULTS.greeting,
reminderGreeting: callReminderGreeting.trim() || CALL_DEFAULTS.reminderGreeting,
newPatientGreeting: callNewPatientGreeting.trim() || CALL_DEFAULTS.newPatientGreeting,
generalFallback: callGeneralFallback.trim() || CALL_DEFAULTS.generalFallback,
});
};
@@ -1197,14 +1262,44 @@ export function AiChatSettingsCard() {
onChange: setGeneralFallback,
placeholder: DEFAULTS.generalFallback,
},
];
const callTemplateFields = [
{
key: "call",
key: "callGreeting",
icon: <PhoneCall className="h-4 w-4 text-primary" />,
label: "Call Template",
description: "Lisa's opening line when placing an AI Call to a patient. Separate from the SMS chat templates above — use {firstName} and {officeName} as placeholders.",
value: callTemplate,
onChange: setCallTemplate,
placeholder: DEFAULTS.callTemplate,
label: "Call Opening Greeting",
description: "Lisa's opening line when placing an AI Call to a patient — use {firstName} and {officeName} as placeholders.",
value: callGreeting,
onChange: setCallGreeting,
placeholder: CALL_DEFAULTS.greeting,
},
{
key: "callReminder",
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
label: "Reminder Call Reply",
description: "Spoken after the call's opening greeting when the AI is following up on an appointment reminder — keep it short, it's read aloud, not texted.",
value: callReminderGreeting,
onChange: setCallReminderGreeting,
placeholder: CALL_DEFAULTS.reminderGreeting,
},
{
key: "callNewPatient",
icon: <UserPlus className="h-4 w-4 text-primary" />,
label: "New Patient Call Reply",
description: "Spoken when the AI is walking a new patient through scheduling on a live call.",
value: callNewPatientGreeting,
onChange: setCallNewPatientGreeting,
placeholder: CALL_DEFAULTS.newPatientGreeting,
},
{
key: "callGeneral",
icon: <MessageCircle className="h-4 w-4 text-primary" />,
label: "General Call Fallback",
description: "Spoken when the AI cannot determine the context of what the patient said on a call.",
value: callGeneralFallback,
onChange: setCallGeneralFallback,
placeholder: CALL_DEFAULTS.generalFallback,
},
];
@@ -1408,7 +1503,85 @@ export function AiChatSettingsCard() {
setReminderGreeting(DEFAULTS.reminderGreeting);
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
setGeneralFallback(DEFAULTS.generalFallback);
setCallTemplate(DEFAULTS.callTemplate);
}}
>
Reset to defaults
</Button>
</div>
</form>
)}
</CardContent>
</Card>
{/* ── Section 1b: Call Templates ───────────────────────────── */}
<Card>
<CardContent className="py-6 space-y-5">
<div className="flex items-center gap-2">
<PhoneCall className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Call Templates</h3>
</div>
<p className="text-sm text-muted-foreground">
Customize what Lisa says on live AI phone calls — kept separate from the SMS wording
above since spoken conversation reads differently than a text message. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{firstName}"}</code>{" "}
as placeholders.
</p>
{officeName && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
<Info className="h-3.5 w-3.5 flex-shrink-0" />
<span>
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
<span className="font-medium text-foreground">"{officeName}"</span>
</span>
</div>
)}
{callTemplatesLoading ? (
<p className="text-sm text-muted-foreground">Loading templates...</p>
) : (
<form onSubmit={handleCallSubmit} className="space-y-5">
{callTemplateFields.map((f) => (
<div key={f.key} className="space-y-1.5">
<div className="flex items-center gap-2">
{f.icon}
<span className="text-sm font-medium">{f.label}</span>
</div>
<p className="text-xs text-muted-foreground">{f.description}</p>
<Textarea
value={f.value}
onChange={(e) => f.onChange(e.target.value)}
placeholder={f.placeholder}
rows={3}
className="text-sm resize-none"
/>
{officeName && f.value.includes("{officeName}") && (
<p className="text-xs text-muted-foreground italic pl-1">
Preview: {previewTemplate(f.value, officeName)}
</p>
)}
</div>
))}
<div className="flex items-center gap-3 pt-1">
<Button
type="submit"
disabled={saveCallMutation.isPending}
className="bg-teal-600 hover:bg-teal-700 text-white"
>
{saveCallMutation.isPending ? "Saving..." : "Save Templates"}
</Button>
<Button
type="button"
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => {
setCallGreeting(CALL_DEFAULTS.greeting);
setCallReminderGreeting(CALL_DEFAULTS.reminderGreeting);
setCallNewPatientGreeting(CALL_DEFAULTS.newPatientGreeting);
setCallGeneralFallback(CALL_DEFAULTS.generalFallback);
}}
>
Reset to defaults