Fix 4 failure modes from the 2026-07-05 live call (the 'Nina' call)
- callstate: corrections win (most recent caller statement replaces old
slots — 'no, my name is Carlos' was ignored); patient_name only from the
caller explicitly giving their own name; booking classified only on
explicit scheduling intent (small talk flipped a call to booking and
drove a re-ask loop); new will_call_back call type short-circuits to a
warm close instead of a message-taking flow.
- bot: per-call _today_line() injects the real current date (spoken form,
answer-only) — AVA guessed 'Tuesday' on a Sunday and argued; hours rule
now also bans stating open/closed days ('closed on Sundays' was invented).
- EndCallProcessor: phone-confirm gate reads CallState (booking/callback +
a captured name or reason) instead of the keyword heuristic when tracking
is on, and the injected confirmation now ends the call — the goodbye had
already played, and holding the line open confused the caller.
Verified: staged replay of the failed transcript on activeblue-avc:latest
(4 stages x 3 runs, all pass, incl. clean-booking control); ab_replay.py
--state 0 failures, latency med 0.52s. Deployed via systemd restart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
80
bot.py
80
bot.py
@@ -11,6 +11,7 @@ the FastAPI/TwiML/WebSocket side and calls run_call() once per call.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
|
||||
import re
|
||||
@@ -262,7 +263,7 @@ SYSTEM_PROMPT = (
|
||||
"is 'booked', 'scheduled', 'set', or 'confirmed' — not even in the recap. It is always a "
|
||||
"REQUEST: say you've NOTED it and a staff member will call back to confirm the date and time.\n"
|
||||
"- Hours are not published — say they vary by office and staff will confirm; never give "
|
||||
"specific hours.\n"
|
||||
"specific hours and never say which days an office is open or closed.\n"
|
||||
"- You don't give medical advice and can't transfer calls. If the caller mentions an eye "
|
||||
"problem, just note it as the reason and say a staff member or doctor will follow up.\n"
|
||||
"- If you're not sure you heard something, simply ask them to repeat it.\n"
|
||||
@@ -274,6 +275,28 @@ SYSTEM_PROMPT = (
|
||||
"PRACTICE FACTS:\n" + practice_summary()
|
||||
)
|
||||
|
||||
_DAY_ORDINALS = (
|
||||
"first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth",
|
||||
"tenth", "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", "sixteenth",
|
||||
"seventeenth", "eighteenth", "nineteenth", "twentieth", "twenty-first", "twenty-second",
|
||||
"twenty-third", "twenty-fourth", "twenty-fifth", "twenty-sixth", "twenty-seventh",
|
||||
"twenty-eighth", "twenty-ninth", "thirtieth", "thirty-first",
|
||||
)
|
||||
|
||||
|
||||
def _today_line() -> str:
|
||||
"""One spoken-form line with the real current date, built fresh per call (the service
|
||||
runs for days). Lets the agent answer 'what day is it?' truthfully — a live call had it
|
||||
guess 'Tuesday' on a Sunday and argue — WITHOUT reintroducing in-call date math:
|
||||
appointment dates stay capture-and-defer (see the DATES rule)."""
|
||||
now = datetime.datetime.now()
|
||||
return (
|
||||
f"\n\nTODAY: right now it is {now:%A}, {now:%B} {_DAY_ORDINALS[now.day - 1]}. If the "
|
||||
"caller asks the day or date, answer from this line only. This changes nothing else: "
|
||||
"still take appointment days in the caller's own words, and never compute, state, or "
|
||||
"correct an appointment's calendar date."
|
||||
)
|
||||
|
||||
|
||||
def _build_tools() -> ToolsSchema:
|
||||
# Only the booking action is a tool. Practice facts already live in the system prompt,
|
||||
@@ -309,17 +332,24 @@ class EndCallProcessor(FrameProcessor):
|
||||
(TwilioFrameSerializer auto_hang_up drops the call).
|
||||
|
||||
Deterministic phone confirmation: the prompt asks the agent to read the callback number
|
||||
back, but the 8B skips it ~half the time. So if a closing is reached and the agent never
|
||||
spoke the number this call (`phone_marker` not seen in its replies), we suppress the
|
||||
hang-up and inject a scripted confirmation turn first — guaranteeing it happens exactly
|
||||
once (the agent's own readback satisfies the gate, so no double-ask in the common case)."""
|
||||
back, but the 8B skips it ~half the time. So if a closing is reached on a call with a real
|
||||
capture and the agent never spoke the number (`phone_marker` not seen in its replies), the
|
||||
scripted confirmation line is injected after the goodbye and the call then hangs up —
|
||||
since the goodbye has already been spoken, holding the line open afterwards just confuses
|
||||
the caller (live call 2026-07-05: 'did you say goodbye, or are we continuing?'). The
|
||||
agent's own readback satisfies the gate, so no double-ask in the common case.
|
||||
|
||||
Whether a capture is real comes from CallState via `state_provider` when tracking is on
|
||||
(a misheard word once flipped a call to 'booking' and forced a number confirmation nobody
|
||||
wanted); without tracking it falls back to the booking-keyword heuristic."""
|
||||
|
||||
_CLOSINGS = ("goodbye", "good-bye", "good bye", "adiós", "adios", "hasta luego")
|
||||
# Only force phone confirmation when a booking was actually underway (not info-only calls).
|
||||
# Fallback heuristic when no CallState is available (e.g. anthropic path).
|
||||
_BOOKING_KWS = ("appointment", "schedule", "book", "insurance", "what day", "what time",
|
||||
"come in", "preferred")
|
||||
|
||||
def __init__(self, phone_confirm_line: str | None = None, phone_marker: str | None = None):
|
||||
def __init__(self, phone_confirm_line: str | None = None, phone_marker: str | None = None,
|
||||
state_provider=None):
|
||||
super().__init__()
|
||||
self._buf = ""
|
||||
self._should_end = False
|
||||
@@ -330,12 +360,25 @@ class EndCallProcessor(FrameProcessor):
|
||||
self._phone_confirmed = not phone_confirm_line
|
||||
self._assistant_seen = ""
|
||||
self._pending_phone_inject = False
|
||||
self._state_provider = state_provider
|
||||
|
||||
@classmethod
|
||||
def _is_closing(cls, text: str) -> bool:
|
||||
t = (text or "").lower()
|
||||
return any(c in t for c in cls._CLOSINGS)
|
||||
|
||||
def _capture_underway(self) -> bool:
|
||||
"""Does this call actually need the phone confirmation? With CallState: only a
|
||||
booking/callback call that captured a name or reason. Without: keyword heuristic."""
|
||||
state = self._state_provider() if self._state_provider else None
|
||||
if state:
|
||||
ctype = (state.get("call_type") or "").strip().lower()
|
||||
def _s(key):
|
||||
v = state.get(key)
|
||||
return v.strip() if isinstance(v, str) else ""
|
||||
return ctype in ("booking", "callback") and bool(_s("patient_name") or _s("reason"))
|
||||
return any(k in self._assistant_seen for k in self._BOOKING_KWS)
|
||||
|
||||
async def _hang_up_after_delay(self):
|
||||
await asyncio.sleep(HANGUP_DELAY_SECS)
|
||||
logger.info(f"{AGENT_NAME} ending task / hanging up")
|
||||
@@ -353,20 +396,23 @@ class EndCallProcessor(FrameProcessor):
|
||||
self._phone_confirmed = True # the agent read the number back itself
|
||||
elif isinstance(frame, LLMFullResponseEndFrame):
|
||||
if self._is_closing(self._buf):
|
||||
booking = any(k in self._assistant_seen for k in self._BOOKING_KWS)
|
||||
if self._phone_confirmed or not booking:
|
||||
if self._phone_confirmed or not self._capture_underway():
|
||||
self._should_end = True
|
||||
logger.info(f"{AGENT_NAME} signalled closing -- will hang up "
|
||||
f"{HANGUP_DELAY_SECS:.0f}s after she finishes speaking")
|
||||
else:
|
||||
# Booking call closing without the number confirmed — do it deterministically.
|
||||
# Real capture closing without the number confirmed — speak it, then end.
|
||||
self._pending_phone_inject = True
|
||||
logger.info(f"{AGENT_NAME} reached closing w/o phone confirmation -- injecting it")
|
||||
logger.info(f"{AGENT_NAME} reached closing w/o phone confirmation -- "
|
||||
"injecting it, then hanging up")
|
||||
self._buf = ""
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||
if self._pending_phone_inject:
|
||||
self._pending_phone_inject = False
|
||||
self._phone_confirmed = True
|
||||
# The goodbye already played — after the injected line finishes (next
|
||||
# BotStoppedSpeakingFrame), hang up instead of leaving the line open.
|
||||
self._should_end = True
|
||||
await self.push_frame(TTSSpeakFrame(self._phone_confirm_line), FrameDirection.DOWNSTREAM)
|
||||
elif self._should_end:
|
||||
self._should_end = False
|
||||
@@ -707,7 +753,7 @@ async def run_agent(transport, caller_number=None, call_sid=None, do_capture=Tru
|
||||
"\n\nCALLER ID: no number is available — near the end, ask the caller for the best "
|
||||
"phone number to reach them."
|
||||
)
|
||||
system_content = SYSTEM_PROMPT + caller_line
|
||||
system_content = SYSTEM_PROMPT + _today_line() + caller_line
|
||||
context_kwargs = {"messages": [{"role": "system", "content": system_content}]}
|
||||
if ENABLE_TOOLS:
|
||||
context_kwargs["tools"] = _build_tools()
|
||||
@@ -756,13 +802,19 @@ async def run_agent(transport, caller_number=None, call_sid=None, do_capture=Tru
|
||||
# having read the caller-ID back, EndCallProcessor speaks this scripted line first.
|
||||
if caller_number:
|
||||
_spoken = _spoken_phone(caller_number)
|
||||
# Spoken AFTER the goodbye and the call then hangs up, so the correction path must
|
||||
# not need a reply on this call — staff have the number for the callback anyway.
|
||||
phone_confirm_line = (
|
||||
f"Also, I have your number as {_spoken}; if that's not the best number, just let me know."
|
||||
f"One last thing — I have your number as {_spoken} for the callback; if that's "
|
||||
"not the best number, please give us a quick call back. Thank you, goodbye!"
|
||||
)
|
||||
phone_marker = _spoken.split(",")[0].strip() # e.g. "nine seven three"
|
||||
else:
|
||||
phone_confirm_line = phone_marker = None
|
||||
endcall = EndCallProcessor(phone_confirm_line=phone_confirm_line, phone_marker=phone_marker)
|
||||
endcall = EndCallProcessor(
|
||||
phone_confirm_line=phone_confirm_line, phone_marker=phone_marker,
|
||||
state_provider=(lambda: groomer.state) if groomer else None,
|
||||
)
|
||||
|
||||
watchdog = SilenceWatchdog(
|
||||
silence_secs=SILENCE_REPROMPT_SECS,
|
||||
|
||||
Reference in New Issue
Block a user