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
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
View File

@@ -2,8 +2,8 @@
.env
# Runtime logs
avc_run.log
*.log
avc_run.log*
# Recordings (local only, may contain PHI)
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.
"""
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)

View File

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

View File

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

View File

@@ -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')}")