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