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

View File

@@ -52,3 +52,8 @@ WHISPER_DEVICE=cuda
WHISPER_COMPUTE=float16 WHISPER_COMPUTE=float16
KOKORO_VOICE=af_heart KOKORO_VOICE=af_heart
KOKORO_MODEL_DIR=/home/tocmo0nlord/pipecat-run/models KOKORO_MODEL_DIR=/home/tocmo0nlord/pipecat-run/models
# ── Call behaviour ───────────────────────────────────────────────────────────
AGENT_NAME=AVA
# Grace pause after the goodbye before the carrier leg is dropped (seconds).
HANGUP_DELAY_SECS=4.0

2
.gitignore vendored
View File

@@ -2,8 +2,8 @@
.env .env
# Runtime logs # Runtime logs
avc_run.log
*.log *.log
avc_run.log*
# Recordings (local only, may contain PHI) # Recordings (local only, may contain PHI)
recordings/ recordings/

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. the FastAPI/TwiML/WebSocket side and calls run_call() once per call.
""" """
import asyncio
import os import os
import re import re
import time import time
from datetime import datetime, timedelta
from loguru import logger 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 persona name — purely for warmth; change/remove freely.
AGENT_NAME = os.environ.get("AGENT_NAME", "Sofia") 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 = ( SYSTEM_PROMPT = (
f"You are {AGENT_NAME}, a warm, friendly receptionist for Advanced Vision Care, an " 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 " "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 " "just one short sentence at a time. Speak in English. Say numbers, dates, and times as "
"words a person would say.\n\n" "words a person would say.\n\n"
"Your job is to answer callers' questions and to take appointment requests. To book a " "Your job is to answer callers' questions and to take appointment requests. For a "
"visit you need four things: which office or city, the reason for the visit, a preferred " "booking, gather these SIX things naturally as the conversation flows — don't "
"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:\n"
"interrogate, and never ask for something the caller already told you (people often give " " 1. Their FULL name (first and last). If they give only a first name, warmly ask for "
"their name or reason in their first sentence). You already have their number from caller " "their last name too.\n"
"ID, so never ask for a phone number. When you have the details, repeat them back in one " " 2. The phone number to reach them. Their caller-ID number is given to you below — read "
"warm sentence to confirm, and let them know a staff member will call to finalize the time.\n\n" "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" "Stay truthful and within your limits:\n"
"- Use ONLY the facts below for addresses, phone numbers, insurance, and services. Never " "- Use ONLY the facts below for addresses, phone numbers, insurance, and services. Never "
"make any of these up.\n" "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 " "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 " "they tell you their area, name the matching office; and only list locations if they ask "
"what offices exist.\n" "what offices exist.\n"
"- You cannot see a calendar, so never say a time is open or available — take the time as " "- INSURANCE — log only, never promise: ask what insurance they have and note it for staff. "
"a request that staff will confirm.\n" "Do NOT promise, confirm, or deny coverage or any treatment based on their insurance, even "
"- Insurance: only confirm a plan that is in the list below. For any plan that is not " "if the plan is one we list. Always say our staff will verify their coverage when they call "
"listed (UnitedHealthcare, Aetna, Cigna, and so on), don't say yes or no — say our staff " "back. Just capture the plan name.\n"
"will verify their coverage.\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 " "- Hours are not published — say they vary by office and staff will confirm; never give "
"specific hours.\n" "specific hours.\n"
"- You don't give medical advice and can't transfer calls. If the caller mentions an eye " "- 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: def _build_tools() -> ToolsSchema:
# Only the booking action is a tool. Practice facts already live in the system prompt, # 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 # 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): 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 text (LLMTextFrame, flowing downstream) AND the upstream copy of BotStoppedSpeakingFrame
the output transport emits. It accumulates each reply; if the finished reply contains a 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 closing ('goodbye'/'adiós'), it waits until she's done speaking, pauses HANGUP_DELAY_SECS
upstream — the task ends and TwilioFrameSerializer (auto_hang_up) drops the call.""" 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") _CLOSINGS = ("goodbye", "good-bye", "good bye", "adiós", "adios", "hasta luego")
@@ -178,12 +219,21 @@ class EndCallProcessor(FrameProcessor):
super().__init__() super().__init__()
self._buf = "" self._buf = ""
self._should_end = False self._should_end = False
self._end_task = None
@classmethod @classmethod
def _is_closing(cls, text: str) -> bool: def _is_closing(cls, text: str) -> bool:
t = (text or "").lower() t = (text or "").lower()
return any(c in t for c in cls._CLOSINGS) 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): async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
if isinstance(frame, LLMTextFrame): if isinstance(frame, LLMTextFrame):
@@ -191,12 +241,14 @@ class EndCallProcessor(FrameProcessor):
elif isinstance(frame, LLMFullResponseEndFrame): elif isinstance(frame, LLMFullResponseEndFrame):
if self._is_closing(self._buf): if self._is_closing(self._buf):
self._should_end = True 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 = "" self._buf = ""
elif isinstance(frame, BotStoppedSpeakingFrame) and self._should_end: elif isinstance(frame, BotStoppedSpeakingFrame) and self._should_end:
self._should_end = False self._should_end = False
logger.info("Sofia closed the call -- ending task / hanging up") # Schedule the teardown so we don't block the pipeline during the grace pause.
await self.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM) if self._end_task is None:
self._end_task = asyncio.create_task(self._hang_up_after_delay())
await self.push_frame(frame, direction) 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() 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: if ENABLE_TOOLS:
context_kwargs["tools"] = _build_tools() context_kwargs["tools"] = _build_tools()
context = LLMContext(**context_kwargs) context = LLMContext(**context_kwargs)

View File

@@ -8,6 +8,7 @@ single JSON-mode completion, not mid-conversation tool emission.
import json import json
import re import re
from datetime import datetime
import httpx import httpx
from loguru import logger from loguru import logger
@@ -19,11 +20,16 @@ _EXTRACT_INSTRUCTIONS = (
"for an optometry practice. Extract any APPOINTMENT REQUEST the caller made.\n" "for an optometry practice. Extract any APPOINTMENT REQUEST the caller made.\n"
"Respond with ONLY a JSON object with these keys:\n" "Respond with ONLY a JSON object with these keys:\n"
' "wants_appointment": boolean — true only if the caller asked to book/schedule a visit\n' ' "wants_appointment": boolean — true only if the caller asked to book/schedule a visit\n'
' "patient_name": string or null\n' ' "patient_name": string or null — the caller\'s FULL name (first and last) if given\n'
' "callback_number": string or null (digits the caller gave to be called back)\n' ' "phone_ok": boolean or null — true if the caller confirmed their caller-ID number is the '
"best one, false if they said to use a different number, null if it never came up\n"
' "alternate_number": string or null — a different callback number the caller gave, digits only\n'
' "location": string or null (which office/city)\n' ' "location": string or null (which office/city)\n'
' "reason": string or null (e.g. eye exam, broken glasses)\n' ' "reason": string or null (e.g. eye exam, broken glasses)\n'
' "preferred_time": string or null (day/time in the caller\'s words)\n' ' "insurance": string or null — the insurance plan the caller named, exactly as they said it\n'
' "preferred_time": string or null — the day/time in the caller\'s own words\n'
' "resolved_date": string or null — the actual calendar date the caller means as YYYY-MM-DD, '
"computed from TODAY given below; null if it cannot be determined\n"
"Use null for anything not clearly stated. Do not invent values." "Use null for anything not clearly stated. Do not invent values."
) )
@@ -45,6 +51,7 @@ async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_
if base.endswith("/v1"): if base.endswith("/v1"):
base = base[:-3] base = base[:-3]
today = datetime.now().strftime("%A, %B %d, %Y").replace(" 0", " ")
try: try:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
r = await client.post( r = await client.post(
@@ -55,7 +62,7 @@ async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_
"stream": False, "stream": False,
"options": {"temperature": 0}, "options": {"temperature": 0},
"messages": [ "messages": [
{"role": "system", "content": _EXTRACT_INSTRUCTIONS}, {"role": "system", "content": f"{_EXTRACT_INSTRUCTIONS}\n\nTODAY is {today}."},
{"role": "user", "content": f"Transcript:\n{transcript}"}, {"role": "user", "content": f"Transcript:\n{transcript}"},
], ],
}, },
@@ -78,24 +85,44 @@ async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_
logger.info("Post-call extraction: appointment intent but no name/reason captured — skipping card") logger.info("Post-call extraction: appointment intent but no name/reason captured — skipping card")
return None return None
# Prefer the verified Twilio caller-ID over a number pulled from the transcript — # Callback number: default to the verified Twilio caller-ID. If the caller explicitly
# the model sometimes invents/echoes a phone number. Keep a genuinely different # declined it (phone_ok == False) and gave a different number, use the one they gave.
# spoken number as a note for staff. alt = (data.get("alternate_number") or "").strip()
spoken = (data.get("callback_number") or "").strip() phone_ok = data.get("phone_ok")
callback = caller_number or spoken or None if phone_ok is False and alt:
callback = alt
else:
callback = caller_number or alt or None
# If they gave a different number but didn't clearly decline the caller-ID, keep it as a note.
note_alt = (
alt and caller_number and callback != alt
and re.sub(r"\D", "", alt) != re.sub(r"\D", "", caller_number)
)
# Preferred time = caller's words, annotated with the resolved calendar date if we got one.
preferred = (data.get("preferred_time") or "").strip() or None
resolved = (data.get("resolved_date") or "").strip() or None
if resolved:
preferred = f"{preferred} (resolved: {resolved})" if preferred else resolved
reason = data.get("reason") reason = data.get("reason")
if spoken and caller_number and re.sub(r"\D", "", spoken) != re.sub(r"\D", "", caller_number): if note_alt:
reason = f"{reason or ''} (caller mentioned alternate number: {spoken})".strip() reason = f"{reason or ''} (caller also mentioned number: {alt})".strip()
record = { record = {
"call_sid": call_sid, "call_sid": call_sid,
"patient_name": data.get("patient_name"), "patient_name": data.get("patient_name"),
"callback_number": callback, "callback_number": callback,
"phone_confirmed": phone_ok,
"location": data.get("location"), "location": data.get("location"),
"reason": reason, "reason": reason,
"preferred_time": data.get("preferred_time"), "insurance": (data.get("insurance") or "").strip() or None,
"preferred_time": preferred,
"source": "post_call_extraction", "source": "post_call_extraction",
} }
where = persist_appointment(record) where = persist_appointment(record)
logger.info(f"Post-call appointment saved ({where}): {record['patient_name']} / {record['location']}") logger.info(
f"Post-call appointment saved ({where}): {record['patient_name']} / "
f"{record['location']} / ins={record['insurance']} / when={record['preferred_time']}"
)
return record return record

View File

@@ -58,7 +58,8 @@ def _find_or_create_partner(uid, models, name, phone):
return _exec(uid, models, "res.partner", "create", vals) return _exec(uid, models, "res.partner", "create", vals)
def create_appointment_request(patient_name, callback_number, reason, preferred_time, call_sid=None): def create_appointment_request(patient_name, callback_number, reason, preferred_time,
insurance=None, call_sid=None):
"""Create the request in Odoo. Returns (model, record_id) or raises OdooError.""" """Create the request in Odoo. Returns (model, record_id) or raises OdooError."""
uid, models = _connect() uid, models = _connect()
summary = f"📞 Phone appt request — {patient_name or 'caller'}" summary = f"📞 Phone appt request — {patient_name or 'caller'}"
@@ -67,6 +68,7 @@ def create_appointment_request(patient_name, callback_number, reason, preferred_
("Name", patient_name), ("Name", patient_name),
("Callback", callback_number), ("Callback", callback_number),
("Reason", reason), ("Reason", reason),
("Insurance (log only — staff to verify coverage)", insurance),
("Preferred time (patient's words)", preferred_time), ("Preferred time (patient's words)", preferred_time),
("Twilio call SID", call_sid), ("Twilio call SID", call_sid),
] ]

View File

@@ -110,6 +110,7 @@ def persist_appointment(record: dict) -> str:
callback_number=record.get("callback_number"), callback_number=record.get("callback_number"),
reason=f"[{record.get('location') or 'location TBD'}] {record.get('reason') or ''}".strip(), reason=f"[{record.get('location') or 'location TBD'}] {record.get('reason') or ''}".strip(),
preferred_time=record.get("preferred_time"), preferred_time=record.get("preferred_time"),
insurance=record.get("insurance"),
call_sid=record.get("call_sid"), call_sid=record.get("call_sid"),
) )
logger.info(f"Appointment -> Odoo {model} id={rec_id}: {record.get('patient_name')}") logger.info(f"Appointment -> Odoo {model} id={rec_id}: {record.get('patient_name')}")