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:
@@ -52,3 +52,8 @@ WHISPER_DEVICE=cuda
|
||||
WHISPER_COMPUTE=float16
|
||||
KOKORO_VOICE=af_heart
|
||||
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
2
.gitignore
vendored
@@ -2,8 +2,8 @@
|
||||
.env
|
||||
|
||||
# Runtime logs
|
||||
avc_run.log
|
||||
*.log
|
||||
avc_run.log*
|
||||
|
||||
# Recordings (local only, may contain PHI)
|
||||
recordings/
|
||||
|
||||
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)
|
||||
|
||||
53
extract.py
53
extract.py
@@ -8,6 +8,7 @@ single JSON-mode completion, not mid-conversation tool emission.
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -19,11 +20,16 @@ _EXTRACT_INSTRUCTIONS = (
|
||||
"for an optometry practice. Extract any APPOINTMENT REQUEST the caller made.\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'
|
||||
' "patient_name": string or null\n'
|
||||
' "callback_number": string or null (digits the caller gave to be called back)\n'
|
||||
' "patient_name": string or null — the caller\'s FULL name (first and last) if given\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'
|
||||
' "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."
|
||||
)
|
||||
|
||||
@@ -45,6 +51,7 @@ async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_
|
||||
if base.endswith("/v1"):
|
||||
base = base[:-3]
|
||||
|
||||
today = datetime.now().strftime("%A, %B %d, %Y").replace(" 0", " ")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
@@ -55,7 +62,7 @@ async def extract_and_record(messages, ollama_url, model, call_sid=None, caller_
|
||||
"stream": False,
|
||||
"options": {"temperature": 0},
|
||||
"messages": [
|
||||
{"role": "system", "content": _EXTRACT_INSTRUCTIONS},
|
||||
{"role": "system", "content": f"{_EXTRACT_INSTRUCTIONS}\n\nTODAY is {today}."},
|
||||
{"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")
|
||||
return None
|
||||
|
||||
# Prefer the verified Twilio caller-ID over a number pulled from the transcript —
|
||||
# the model sometimes invents/echoes a phone number. Keep a genuinely different
|
||||
# spoken number as a note for staff.
|
||||
spoken = (data.get("callback_number") or "").strip()
|
||||
callback = caller_number or spoken or None
|
||||
# Callback number: default to the verified Twilio caller-ID. If the caller explicitly
|
||||
# declined it (phone_ok == False) and gave a different number, use the one they gave.
|
||||
alt = (data.get("alternate_number") or "").strip()
|
||||
phone_ok = data.get("phone_ok")
|
||||
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")
|
||||
if spoken and caller_number and re.sub(r"\D", "", spoken) != re.sub(r"\D", "", caller_number):
|
||||
reason = f"{reason or ''} (caller mentioned alternate number: {spoken})".strip()
|
||||
if note_alt:
|
||||
reason = f"{reason or ''} (caller also mentioned number: {alt})".strip()
|
||||
|
||||
record = {
|
||||
"call_sid": call_sid,
|
||||
"patient_name": data.get("patient_name"),
|
||||
"callback_number": callback,
|
||||
"phone_confirmed": phone_ok,
|
||||
"location": data.get("location"),
|
||||
"reason": reason,
|
||||
"preferred_time": data.get("preferred_time"),
|
||||
"insurance": (data.get("insurance") or "").strip() or None,
|
||||
"preferred_time": preferred,
|
||||
"source": "post_call_extraction",
|
||||
}
|
||||
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
|
||||
|
||||
@@ -58,7 +58,8 @@ def _find_or_create_partner(uid, models, name, phone):
|
||||
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."""
|
||||
uid, models = _connect()
|
||||
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),
|
||||
("Callback", callback_number),
|
||||
("Reason", reason),
|
||||
("Insurance (log only — staff to verify coverage)", insurance),
|
||||
("Preferred time (patient's words)", preferred_time),
|
||||
("Twilio call SID", call_sid),
|
||||
]
|
||||
|
||||
@@ -110,6 +110,7 @@ def persist_appointment(record: dict) -> str:
|
||||
callback_number=record.get("callback_number"),
|
||||
reason=f"[{record.get('location') or 'location TBD'}] {record.get('reason') or ''}".strip(),
|
||||
preferred_time=record.get("preferred_time"),
|
||||
insurance=record.get("insurance"),
|
||||
call_sid=record.get("call_sid"),
|
||||
)
|
||||
logger.info(f"Appointment -> Odoo {model} id={rec_id}: {record.get('patient_name')}")
|
||||
|
||||
Reference in New Issue
Block a user