Capture full appointment details + validate dates in-call
In-call (system prompt + per-call calendar injection): - Gather full name (prompt asks for last name if only first given). - Confirm the caller-ID number; if declined, use the number the caller gives. - Ask for and LOG insurance only — never promise/confirm/deny coverage or treatment based on it; staff verify on callback. - Validate the requested date against an injected 45-day calendar (recomputed per call since the server is long-running). Push back on impossible/mismatched dates, e.g. "Monday lands on the sixth — would you like that date?". - AGENT_NAME=AVA; 4s grace pause before hang-up (HANGUP_DELAY_SECS). Logging (post-call extraction -> Odoo): - Extract full name, phone_confirmed, chosen callback (caller-ID or alternate), insurance, reason, and preferred time annotated with a resolved YYYY-MM-DD date (today's date is fed to the extractor). - odoo_client: insurance row on the lead note (log only — staff verify). .gitignore: ignore rotated avc_run.log* files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
103
bot.py
103
bot.py
@@ -10,10 +10,12 @@ This module just builds + runs the pipeline for one connected call. server.py ow
|
||||
the FastAPI/TwiML/WebSocket side and calls run_call() once per call.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -103,6 +105,10 @@ VAD_STOP_SECS = float(os.environ.get("VAD_STOP_SECS", "0.5"))
|
||||
|
||||
# Agent persona name — purely for warmth; change/remove freely.
|
||||
AGENT_NAME = os.environ.get("AGENT_NAME", "Sofia")
|
||||
# Grace period after the agent finishes the goodbye before we drop the carrier leg, so
|
||||
# the caller isn't cut off mid-word. The hang-up itself (EndTaskFrame -> auto_hang_up)
|
||||
# is unchanged — this only delays it.
|
||||
HANGUP_DELAY_SECS = float(os.environ.get("HANGUP_DELAY_SECS", "4.0"))
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
f"You are {AGENT_NAME}, a warm, friendly receptionist for Advanced Vision Care, an "
|
||||
@@ -110,13 +116,21 @@ SYSTEM_PROMPT = (
|
||||
"talk like a helpful human being: natural, relaxed, and genuinely conversational — usually "
|
||||
"just one short sentence at a time. Speak in English. Say numbers, dates, and times as "
|
||||
"words a person would say.\n\n"
|
||||
"Your job is to answer callers' questions and to take appointment requests. To book a "
|
||||
"visit you need four things: which office or city, the reason for the visit, a preferred "
|
||||
"day and time, and their name. Gather these naturally as the conversation flows — don't "
|
||||
"interrogate, and never ask for something the caller already told you (people often give "
|
||||
"their name or reason in their first sentence). You already have their number from caller "
|
||||
"ID, so never ask for a phone number. When you have the details, repeat them back in one "
|
||||
"warm sentence to confirm, and let them know a staff member will call to finalize the time.\n\n"
|
||||
"Your job is to answer callers' questions and to take appointment requests. For a "
|
||||
"booking, gather these SIX things naturally as the conversation flows — don't "
|
||||
"interrogate, and never ask for something the caller already told you:\n"
|
||||
" 1. Their FULL name (first and last). If they give only a first name, warmly ask for "
|
||||
"their last name too.\n"
|
||||
" 2. The phone number to reach them. Their caller-ID number is given to you below — read "
|
||||
"it back and ask if that is the best number. If they say no, ask for the right number and "
|
||||
"use that instead.\n"
|
||||
" 3. Which office or city is most convenient.\n"
|
||||
" 4. The reason for the visit.\n"
|
||||
" 5. Their insurance — ask what insurance they have and simply note it (see the insurance "
|
||||
"rule below).\n"
|
||||
" 6. The day and time they prefer (validate the date — see the date rule below).\n"
|
||||
"When you have the details, repeat them back in one warm sentence to confirm, and let them "
|
||||
"know a staff member will call to finalize the time.\n\n"
|
||||
"Stay truthful and within your limits:\n"
|
||||
"- Use ONLY the facts below for addresses, phone numbers, insurance, and services. Never "
|
||||
"make any of these up.\n"
|
||||
@@ -124,11 +138,18 @@ SYSTEM_PROMPT = (
|
||||
"NOT suggest or name a specific office yourself — you don't know where they are. Only after "
|
||||
"they tell you their area, name the matching office; and only list locations if they ask "
|
||||
"what offices exist.\n"
|
||||
"- You cannot see a calendar, so never say a time is open or available — take the time as "
|
||||
"a request that staff will confirm.\n"
|
||||
"- Insurance: only confirm a plan that is in the list below. For any plan that is not "
|
||||
"listed (UnitedHealthcare, Aetna, Cigna, and so on), don't say yes or no — say our staff "
|
||||
"will verify their coverage.\n"
|
||||
"- INSURANCE — log only, never promise: ask what insurance they have and note it for staff. "
|
||||
"Do NOT promise, confirm, or deny coverage or any treatment based on their insurance, even "
|
||||
"if the plan is one we list. Always say our staff will verify their coverage when they call "
|
||||
"back. Just capture the plan name.\n"
|
||||
"- DATES — always validate against the calendar provided below. Work out the real date the "
|
||||
"caller means and check it. If the weekday and the date they say do not match, or the date "
|
||||
"does not exist, gently correct them and offer the right one, then confirm before booking. "
|
||||
"For example, if they say 'Monday the fifth' but the Monday next month is the sixth, say: "
|
||||
"'Next month, Monday lands on the sixth — would you like to schedule that date?' Never accept "
|
||||
"an impossible or mismatched date silently.\n"
|
||||
"- You cannot see a calendar of openings, so never say a time slot is open or available — "
|
||||
"take the day/time as a request that staff will confirm.\n"
|
||||
"- Hours are not published — say they vary by office and staff will confirm; never give "
|
||||
"specific hours.\n"
|
||||
"- You don't give medical advice and can't transfer calls. If the caller mentions an eye "
|
||||
@@ -140,6 +161,25 @@ SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
def _date_context(now: datetime | None = None) -> str:
|
||||
"""Calendar grounding injected per call so the local model can resolve and VALIDATE
|
||||
the dates a caller mentions (e.g. catch 'Monday the 5th' when the Monday is the 6th).
|
||||
Recomputed each call because the server is long-running."""
|
||||
now = now or datetime.now()
|
||||
today = now.date()
|
||||
# 45 days covers 'next month' references for any call date.
|
||||
lines = []
|
||||
for i in range(45):
|
||||
d = today + timedelta(days=i)
|
||||
tag = " <- TODAY" if i == 0 else (" <- tomorrow" if i == 1 else "")
|
||||
lines.append(f" {d.strftime('%A, %B %d, %Y').replace(' 0', ' ')}{tag}")
|
||||
return (
|
||||
"CALENDAR — authoritative, use for EVERY date the caller mentions:\n"
|
||||
f"Today is {today.strftime('%A, %B %d, %Y').replace(' 0', ' ')}.\n"
|
||||
"Upcoming dates:\n" + "\n".join(lines) + "\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_tools() -> ToolsSchema:
|
||||
# Only the booking action is a tool. Practice facts already live in the system prompt,
|
||||
# so no get_practice_info tool (avoids needless calls/latency). callback_number is NOT
|
||||
@@ -166,11 +206,12 @@ def _build_tools() -> ToolsSchema:
|
||||
|
||||
|
||||
class EndCallProcessor(FrameProcessor):
|
||||
"""Lets Sofia hang up. MUST sit between the LLM and the TTS: there it sees her reply
|
||||
"""Lets the agent hang up. MUST sit between the LLM and the TTS: there it sees her reply
|
||||
text (LLMTextFrame, flowing downstream) AND the upstream copy of BotStoppedSpeakingFrame
|
||||
the output transport emits. It accumulates each reply; if the finished reply contains a
|
||||
closing ('goodbye'/'adiós'), it waits until she's done speaking, then pushes EndTaskFrame
|
||||
upstream — the task ends and TwilioFrameSerializer (auto_hang_up) drops the call."""
|
||||
closing ('goodbye'/'adiós'), it waits until she's done speaking, pauses HANGUP_DELAY_SECS
|
||||
so the caller isn't clipped, then pushes EndTaskFrame upstream — the task ends and
|
||||
TwilioFrameSerializer (auto_hang_up) drops the call."""
|
||||
|
||||
_CLOSINGS = ("goodbye", "good-bye", "good bye", "adiós", "adios", "hasta luego")
|
||||
|
||||
@@ -178,12 +219,21 @@ class EndCallProcessor(FrameProcessor):
|
||||
super().__init__()
|
||||
self._buf = ""
|
||||
self._should_end = False
|
||||
self._end_task = None
|
||||
|
||||
@classmethod
|
||||
def _is_closing(cls, text: str) -> bool:
|
||||
t = (text or "").lower()
|
||||
return any(c in t for c in cls._CLOSINGS)
|
||||
|
||||
async def _hang_up_after_delay(self):
|
||||
await asyncio.sleep(HANGUP_DELAY_SECS)
|
||||
logger.info(f"{AGENT_NAME} ending task / hanging up")
|
||||
try:
|
||||
await self.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
|
||||
except Exception:
|
||||
logger.exception("EndTaskFrame push failed (pipeline already ending?)")
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
if isinstance(frame, LLMTextFrame):
|
||||
@@ -191,12 +241,14 @@ class EndCallProcessor(FrameProcessor):
|
||||
elif isinstance(frame, LLMFullResponseEndFrame):
|
||||
if self._is_closing(self._buf):
|
||||
self._should_end = True
|
||||
logger.info("Sofia signalled closing -- will hang up after she finishes speaking")
|
||||
logger.info(f"{AGENT_NAME} signalled closing -- will hang up "
|
||||
f"{HANGUP_DELAY_SECS:.0f}s after she finishes speaking")
|
||||
self._buf = ""
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame) and self._should_end:
|
||||
self._should_end = False
|
||||
logger.info("Sofia closed the call -- ending task / hanging up")
|
||||
await self.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
|
||||
# Schedule the teardown so we don't block the pipeline during the grace pause.
|
||||
if self._end_task is None:
|
||||
self._end_task = asyncio.create_task(self._hang_up_after_delay())
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
@@ -333,7 +385,20 @@ async def run_agent(transport, caller_number=None, call_sid=None, do_capture=Tru
|
||||
)))
|
||||
heartbeat = AudioHeartbeat()
|
||||
|
||||
context_kwargs = {"messages": [{"role": "system", "content": SYSTEM_PROMPT}]}
|
||||
# Per-call system message = static prompt + today's calendar + the caller-ID number to
|
||||
# confirm. Built here (not at import) so the date is current on a long-running server.
|
||||
if caller_number:
|
||||
caller_line = (
|
||||
f"\n\nCALLER ID: the caller's number on file is {caller_number}. Read it back and "
|
||||
"ask if it's the best number to reach them; if they say no, use the number they give."
|
||||
)
|
||||
else:
|
||||
caller_line = (
|
||||
"\n\nCALLER ID: no number is available — ask the caller for the best phone number "
|
||||
"to reach them."
|
||||
)
|
||||
system_content = SYSTEM_PROMPT + "\n\n" + _date_context() + caller_line
|
||||
context_kwargs = {"messages": [{"role": "system", "content": system_content}]}
|
||||
if ENABLE_TOOLS:
|
||||
context_kwargs["tools"] = _build_tools()
|
||||
context = LLMContext(**context_kwargs)
|
||||
|
||||
Reference in New Issue
Block a user