feat: add AI recall calling — books existing patients for routine cleanings
Adds a new recall conversation flow (recall-graph.ts) triggered from the Patient Connection "AI Call" button: offers an existing patient a routine exam & cleaning, classifies their yes/no reply, then books directly onto the schedule (Column A, 30-minute slots, real conflict checking against office hours and existing appointments) instead of only capturing a preference for staff to follow up on. Also adds a "Voice call" label in the message thread to distinguish call transcripts from SMS, and updates the Call Templates settings UI with an editable recall greeting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX } from "lucide-react";
|
||||
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX, Phone } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { Patient, Communication } from "@repo/db/types";
|
||||
import { format, isToday, isYesterday, parseISO } from "date-fns";
|
||||
@@ -534,6 +534,12 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
|
||||
{patient.firstName[0]}{patient.lastName[0]}
|
||||
</div>
|
||||
<div>
|
||||
{comm.channel === "voice" && (
|
||||
<div className="flex items-center gap-1 mb-1 text-[10px] font-medium text-muted-foreground">
|
||||
<Phone className="h-3 w-3" />
|
||||
Voice call
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
|
||||
</div>
|
||||
@@ -545,6 +551,12 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
|
||||
)}
|
||||
{comm.direction === "outbound" && (
|
||||
<div>
|
||||
{comm.channel === "voice" && (
|
||||
<div className="flex items-center justify-end gap-1 mb-1 text-[10px] font-medium text-muted-foreground">
|
||||
<Phone className="h-3 w-3" />
|
||||
Voice call
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall } from "lucide-react";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall, Stethoscope } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -25,6 +25,7 @@ type AiCallTemplates = {
|
||||
reminderGreeting: string;
|
||||
newPatientGreeting: string;
|
||||
generalFallback: string;
|
||||
recallGreeting: string;
|
||||
};
|
||||
|
||||
type OfficeContact = {
|
||||
@@ -51,6 +52,8 @@ const CALL_DEFAULTS = {
|
||||
reminderGreeting: "Sure, I can help with that.",
|
||||
newPatientGreeting: "No problem, let's get you set up.",
|
||||
generalFallback: "Sure, how can I help?",
|
||||
recallGreeting:
|
||||
"Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning, which most insurance plans cover at no cost. Would you like to schedule one?",
|
||||
};
|
||||
|
||||
const DEFAULT_SMS_TEMPLATES = [
|
||||
@@ -82,18 +85,19 @@ function LangGraphFlow() {
|
||||
const nW = 210;
|
||||
const nx = cx - nW / 2; // 245
|
||||
|
||||
// ── Dual entry nodes ──────────────────────────────────────────────────────
|
||||
const entryW = 192;
|
||||
const lEntryCx = 155;
|
||||
const rEntryCx = 545;
|
||||
const e1y = 14, e1h = 56;
|
||||
// ── Triple entry nodes ─────────────────────────────────────────────────────
|
||||
const entryW = 150;
|
||||
const lEntryCx = 130;
|
||||
const mEntryCx = cx; // 350
|
||||
const rEntryCx = 570;
|
||||
const e1y = 14, e1h = 70;
|
||||
|
||||
// Merge connector (horizontal line where both branches meet)
|
||||
const mergeY = e1y + e1h + 14; // 84
|
||||
// Merge connector (horizontal line where all three entries meet)
|
||||
const mergeY = e1y + e1h + 14; // 98
|
||||
|
||||
// ── Center sequence ───────────────────────────────────────────────────────
|
||||
const n2y = mergeY + 14, n2h = 52; // Patient replies y=98
|
||||
const n3y = n2y + n2h + 14, n3h = 84; // AI classifies y=164
|
||||
const n2y = mergeY + 14, n2h = 52; // Patient replies
|
||||
const n3y = n2y + n2h + 14, n3h = 98; // AI classifies
|
||||
|
||||
const forkHY = n3y + n3h + 22; // 270
|
||||
|
||||
@@ -103,7 +107,7 @@ function LangGraphFlow() {
|
||||
|
||||
// ── YES branch ────────────────────────────────────────────────────────────
|
||||
const yesW = 195;
|
||||
const yes1y = forkHY + 50, yes1h = 88; // y=320
|
||||
const yes1y = forkHY + 50, yes1h = 102; // y=320
|
||||
const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460
|
||||
|
||||
// ── NO / Reschedule branch ────────────────────────────────────────────────
|
||||
@@ -128,7 +132,7 @@ function LangGraphFlow() {
|
||||
viewBox={`0 0 ${W} ${totalH}`}
|
||||
className="w-full max-w-2xl mx-auto"
|
||||
role="img"
|
||||
aria-label="LangGraph Reminder and Reschedule conversation flow"
|
||||
aria-label="LangGraph Reminder, Recall, and Reschedule conversation flow"
|
||||
>
|
||||
<defs>
|
||||
<marker id="rg2ah" markerWidth="10" markerHeight="7"
|
||||
@@ -145,20 +149,29 @@ function LangGraphFlow() {
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* ══ DUAL ENTRY NODES ════════════════════════════════════════════ */}
|
||||
{/* ══ TRIPLE ENTRY NODES ════════════════════════════════════════════ */}
|
||||
|
||||
{/* Left: Reminder SMS */}
|
||||
<rect x={lEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
|
||||
<text x={lEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Office sends Reminder SMS</text>
|
||||
<text x={lEntryCx} y={e1y+40} textAnchor="middle" fontSize={9} fill="#93C5FD">Staff triggers batch send</text>
|
||||
<text x={lEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Office sends Reminder</text>
|
||||
<text x={lEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">SMS or Call</text>
|
||||
<text x={lEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#93C5FD">Staff triggers batch send</text>
|
||||
|
||||
{/* Middle: Recall call/SMS for an existing patient */}
|
||||
<rect x={mEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
|
||||
<text x={mEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#5B21B6">Office call or SMS for</text>
|
||||
<text x={mEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#5B21B6">an existing patient</text>
|
||||
<text x={mEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#C4B5FD">Recall: free exam & cleaning</text>
|
||||
|
||||
{/* Right: Reschedule by Office SMS */}
|
||||
<rect x={rEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
|
||||
<text x={rEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Office sends Reschedule SMS</text>
|
||||
<text x={rEntryCx} y={e1y+40} textAnchor="middle" fontSize={9} fill="#FDBA74">"Reschedule by Office" template</text>
|
||||
<text x={rEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Office sends Reschedule</text>
|
||||
<text x={rEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">SMS</text>
|
||||
<text x={rEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#FDBA74">"Reschedule by Office" template</text>
|
||||
|
||||
{/* Converging lines → horizontal merge → Patient replies */}
|
||||
<line x1={lEntryCx} y1={e1y+e1h} x2={lEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={mEntryCx} y1={e1y+e1h} x2={mEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={rEntryCx} y1={e1y+e1h} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={lEntryCx} y1={mergeY} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
<line x1={cx} y1={mergeY} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||
@@ -173,10 +186,11 @@ function LangGraphFlow() {
|
||||
|
||||
{/* N3: AI classifies */}
|
||||
<rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
|
||||
<text x={cx} y={n3y+20} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text>
|
||||
<text x={cx} y={n3y+36} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Reminder: intro (MSG 1) + reply (MSG 2)</text>
|
||||
<text x={cx} y={n3y+52} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: reply sent directly</text>
|
||||
<text x={cx} y={n3y+70} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Date in reply → AI skips to Day Check</text>
|
||||
<text x={cx} y={n3y+18} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text>
|
||||
<text x={cx} y={n3y+33} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Reminder: intro (MSG 1) + reply (MSG 2)</text>
|
||||
<text x={cx} y={n3y+48} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: reply sent directly</text>
|
||||
<text x={cx} y={n3y+63} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: same as Reschedule flow</text>
|
||||
<text x={cx} y={n3y+82} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Date in reply → AI skips to Day Check</text>
|
||||
|
||||
{/* Fork lines */}
|
||||
<line x1={cx} y1={n3y+n3h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
|
||||
@@ -193,10 +207,11 @@ function LangGraphFlow() {
|
||||
{/* ══ YES BRANCH ══════════════════════════════════════════════════ */}
|
||||
|
||||
<rect x={lcx-yesW/2} y={yes1y} width={yesW} height={yes1h} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
|
||||
<text x={lcx} y={yes1y+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Reminder: Thank you for confirming!</text>
|
||||
<text x={lcx} y={yes1y+34} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day & time?</text>
|
||||
<text x={lcx} y={yes1y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date & time]"</text>
|
||||
<text x={lcx} y={yes1y+70} textAnchor="middle" fontSize={8} fill="#047857">date in reply → Day Check ↘</text>
|
||||
<text x={lcx} y={yes1y+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Reminder: Thank you for confirming!</text>
|
||||
<text x={lcx} y={yes1y+31} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day & time?</text>
|
||||
<text x={lcx} y={yes1y+46} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: What day & time?</text>
|
||||
<text x={lcx} y={yes1y+64} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date & time]"</text>
|
||||
<text x={lcx} y={yes1y+80} textAnchor="middle" fontSize={8} fill="#047857">date in reply → Day Check ↘</text>
|
||||
<line x1={lcx} y1={yes1y+yes1h} x2={lcx} y2={yes2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
|
||||
|
||||
{/* Yes2: Patient thanks */}
|
||||
@@ -206,7 +221,7 @@ function LangGraphFlow() {
|
||||
|
||||
{/* Dashed shortcut: YES with date → Day Check */}
|
||||
<line
|
||||
x1={lcx + yesW/2} y1={yes1y + 52}
|
||||
x1={lcx + yesW/2} y1={yes1y + 64}
|
||||
x2={rcx - noW/2 - 2} y2={no2y + 34}
|
||||
stroke="#047857" strokeWidth={1} strokeDasharray="4 3"
|
||||
markerEnd="url(#rg2short)"
|
||||
@@ -1030,6 +1045,7 @@ export function AiChatSettingsCard() {
|
||||
const [callReminderGreeting, setCallReminderGreeting] = useState(CALL_DEFAULTS.reminderGreeting);
|
||||
const [callNewPatientGreeting, setCallNewPatientGreeting] = useState(CALL_DEFAULTS.newPatientGreeting);
|
||||
const [callGeneralFallback, setCallGeneralFallback] = useState(CALL_DEFAULTS.generalFallback);
|
||||
const [callRecallGreeting, setCallRecallGreeting] = useState(CALL_DEFAULTS.recallGreeting);
|
||||
const callInitialized = useRef(false);
|
||||
|
||||
const [openPhoneReply, setOpenPhoneReply] = useState(false);
|
||||
@@ -1130,6 +1146,7 @@ export function AiChatSettingsCard() {
|
||||
setCallReminderGreeting(callTemplatesData.reminderGreeting || CALL_DEFAULTS.reminderGreeting);
|
||||
setCallNewPatientGreeting(callTemplatesData.newPatientGreeting || CALL_DEFAULTS.newPatientGreeting);
|
||||
setCallGeneralFallback(callTemplatesData.generalFallback || CALL_DEFAULTS.generalFallback);
|
||||
setCallRecallGreeting(callTemplatesData.recallGreeting || CALL_DEFAULTS.recallGreeting);
|
||||
}
|
||||
}, [callTemplatesData]);
|
||||
|
||||
@@ -1215,6 +1232,7 @@ export function AiChatSettingsCard() {
|
||||
reminderGreeting: callReminderGreeting.trim() || CALL_DEFAULTS.reminderGreeting,
|
||||
newPatientGreeting: callNewPatientGreeting.trim() || CALL_DEFAULTS.newPatientGreeting,
|
||||
generalFallback: callGeneralFallback.trim() || CALL_DEFAULTS.generalFallback,
|
||||
recallGreeting: callRecallGreeting.trim() || CALL_DEFAULTS.recallGreeting,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1268,12 +1286,21 @@ export function AiChatSettingsCard() {
|
||||
{
|
||||
key: "callGreeting",
|
||||
icon: <PhoneCall className="h-4 w-4 text-primary" />,
|
||||
label: "Call Opening Greeting",
|
||||
label: "Call an existing patient for an appointment",
|
||||
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: "callRecall",
|
||||
icon: <Stethoscope className="h-4 w-4 text-primary" />,
|
||||
label: "Recall Call: Existing Patient Check-Up Offer",
|
||||
description: "Lisa's opening line for a Patient Connection \"AI Call\" — offers an existing patient a routine exam & cleaning (most insurance covers it at no cost) and asks if they'd like to book. Use {firstName} and {officeName} as placeholders.",
|
||||
value: callRecallGreeting,
|
||||
onChange: setCallRecallGreeting,
|
||||
placeholder: CALL_DEFAULTS.recallGreeting,
|
||||
},
|
||||
{
|
||||
key: "callReminder",
|
||||
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
|
||||
@@ -1582,6 +1609,7 @@ export function AiChatSettingsCard() {
|
||||
setCallReminderGreeting(CALL_DEFAULTS.reminderGreeting);
|
||||
setCallNewPatientGreeting(CALL_DEFAULTS.newPatientGreeting);
|
||||
setCallGeneralFallback(CALL_DEFAULTS.generalFallback);
|
||||
setCallRecallGreeting(CALL_DEFAULTS.recallGreeting);
|
||||
}}
|
||||
>
|
||||
Reset to defaults
|
||||
|
||||
Reference in New Issue
Block a user