From b8c71b15c2d20cf38a84ecc0c295b2d2be0df7fb Mon Sep 17 00:00:00 2001 From: tocmo0nlord Date: Thu, 25 Jun 2026 03:00:35 +0000 Subject: [PATCH] Capture full appointment details + validate dates in-call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 +++ .gitignore | 2 +- bot.py | 103 ++++++++++++++++++++++++++++++++++++++++--------- extract.py | 53 ++++++++++++++++++------- odoo_client.py | 4 +- practice.py | 1 + 6 files changed, 134 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index 63a7db3..14a818e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index b5675ce..d0e962e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,8 @@ .env # Runtime logs -avc_run.log *.log +avc_run.log* # Recordings (local only, may contain PHI) recordings/ diff --git a/bot.py b/bot.py index 3281fa0..908949c 100644 --- a/bot.py +++ b/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) diff --git a/extract.py b/extract.py index f33501d..a93fca7 100644 --- a/extract.py +++ b/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 diff --git a/odoo_client.py b/odoo_client.py index 768d288..b248bab 100644 --- a/odoo_client.py +++ b/odoo_client.py @@ -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), ] diff --git a/practice.py b/practice.py index 3c6ec5d..31a6f9d 100644 --- a/practice.py +++ b/practice.py @@ -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')}")