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:
tocmo0nlord
2026-06-25 03:00:35 +00:00
parent 93620be9bb
commit b8c71b15c2
6 changed files with 134 additions and 34 deletions

103
bot.py
View File

@@ -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)