Refactor callback to event

This commit is contained in:
Mark Backman
2025-08-12 15:35:33 -04:00
parent 1c1ee94074
commit 446bb5cddf
2 changed files with 69 additions and 68 deletions

View File

@@ -50,27 +50,6 @@ transport_params = {
} }
async def handle_voicemail(processor):
"""Called when a voicemail is detected.
Args:
processor: The VoicemailProcessor instance. processor.push_frame() is
available to push frames.
"""
logger.info("Voicemail detected! Leaving a message...")
# Push frames using standard Pipecat pattern
await processor.push_frame(
TTSSpeakFrame(
"Hello, this is Jamie calling about your appointment. Please call me back at 555-0123 when you get this."
)
)
# NOTE: A common pattern is to end pipeline after the voicemail is left.
# Uncomment the following line to end the pipeline after leaving the voicemail.
# await processor.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Starting bot") logger.info(f"Starting bot")
@@ -84,7 +63,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
classifier_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) classifier_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
voicemail = VoicemailDetector(llm=classifier_llm, on_voicemail_detected=handle_voicemail) voicemail = VoicemailDetector(llm=classifier_llm)
messages = [ messages = [
{ {
@@ -128,6 +107,21 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info(f"Client disconnected") logger.info(f"Client disconnected")
await task.cancel() await task.cancel()
@voicemail.event_handler("on_voicemail_detected")
async def handle_voicemail(processor):
logger.info("Voicemail detected! Leaving a message...")
# Push frames using standard Pipecat pattern
await processor.push_frame(
TTSSpeakFrame(
"Hello, this is Jamie calling about your appointment. Please call me back at 555-0123 when you get this."
)
)
# NOTE: A common pattern is to end pipeline after the voicemail is left.
# Uncomment the following line to end the pipeline after leaving the voicemail.
# await processor.push_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
await runner.run(task) await runner.run(task)

View File

@@ -13,7 +13,7 @@ a bot needs to determine if a human answered or if the call went to voicemail.
""" """
import asyncio import asyncio
from typing import Awaitable, Callable, List, Optional from typing import List, Optional
from loguru import logger from loguru import logger
@@ -208,7 +208,7 @@ class ConversationGate(FrameProcessor):
class ClassificationProcessor(FrameProcessor): class ClassificationProcessor(FrameProcessor):
"""Processor that handles LLM classification responses and triggers callbacks. """Processor that handles LLM classification responses and triggers events.
This processor aggregates LLM text tokens into complete responses and analyzes This processor aggregates LLM text tokens into complete responses and analyzes
them to determine if the call reached a voicemail system or a live person. them to determine if the call reached a voicemail system or a live person.
@@ -218,9 +218,9 @@ class ClassificationProcessor(FrameProcessor):
The processor expects responses containing either "CONVERSATION" (indicating The processor expects responses containing either "CONVERSATION" (indicating
a human answered) or "VOICEMAIL" (indicating an automated system). Once a a human answered) or "VOICEMAIL" (indicating an automated system). Once a
decision is made, it triggers the appropriate notifications and callbacks. decision is made, it triggers the appropriate notifications and event handlers.
For voicemail detection, the callback timer starts immediately and is cancelled For voicemail detection, the event handler timer starts immediately and is cancelled
and restarted based on user speech patterns to ensure proper timing. and restarted based on user speech patterns to ensure proper timing.
""" """
@@ -230,9 +230,6 @@ class ClassificationProcessor(FrameProcessor):
gate_notifier: BaseNotifier, gate_notifier: BaseNotifier,
conversation_notifier: BaseNotifier, conversation_notifier: BaseNotifier,
voicemail_notifier: BaseNotifier, voicemail_notifier: BaseNotifier,
on_voicemail_detected: Optional[
Callable[["ClassificationProcessor"], Awaitable[None]]
] = None,
voicemail_response_delay: float, voicemail_response_delay: float,
): ):
"""Initialize the voicemail processor. """Initialize the voicemail processor.
@@ -244,20 +241,19 @@ class ClassificationProcessor(FrameProcessor):
all buffered TTS frames for normal conversation flow. all buffered TTS frames for normal conversation flow.
voicemail_notifier: Notifier to signal the TTSBuffer to clear voicemail_notifier: Notifier to signal the TTSBuffer to clear
buffered TTS frames since voicemail was detected. buffered TTS frames since voicemail was detected.
on_voicemail_detected: Optional callback function called when voicemail
is detected. The callback receives this processor instance and can
use it to push custom frames (like voicemail greetings).
voicemail_response_delay: Delay in seconds after user stops speaking voicemail_response_delay: Delay in seconds after user stops speaking
before triggering the voicemail callback. This ensures the voicemail before triggering the voicemail event handler. This ensures the voicemail
greeting or user message is complete before responding. greeting or user message is complete before responding.
""" """
super().__init__() super().__init__()
self._gate_notifier = gate_notifier self._gate_notifier = gate_notifier
self._conversation_notifier = conversation_notifier self._conversation_notifier = conversation_notifier
self._voicemail_notifier = voicemail_notifier self._voicemail_notifier = voicemail_notifier
self._on_voicemail_detected = on_voicemail_detected
self._voicemail_response_delay = voicemail_response_delay self._voicemail_response_delay = voicemail_response_delay
# Register the voicemail detected event
self._register_event_handler("on_voicemail_detected")
# Aggregation state for collecting complete LLM responses # Aggregation state for collecting complete LLM responses
self._processing_response = False self._processing_response = False
self._response_buffer = "" self._response_buffer = ""
@@ -265,7 +261,7 @@ class ClassificationProcessor(FrameProcessor):
# Voicemail timing state # Voicemail timing state
self._voicemail_detected = False self._voicemail_detected = False
self._voicemail_callback_task: Optional[asyncio.Task] = None self._voicemail_handler_task: Optional[asyncio.Task] = None
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process frames and handle LLM classification responses. """Process frames and handle LLM classification responses.
@@ -299,15 +295,15 @@ class ClassificationProcessor(FrameProcessor):
self._response_buffer += frame.text self._response_buffer += frame.text
elif isinstance(frame, UserStartedSpeakingFrame): elif isinstance(frame, UserStartedSpeakingFrame):
# User started speaking - cancel voicemail callback timer # User started speaking - cancel voicemail handler timer
if self._voicemail_callback_task: if self._voicemail_handler_task:
await self.cancel_task(self._voicemail_callback_task) await self.cancel_task(self._voicemail_handler_task)
self._voicemail_callback_task = None self._voicemail_handler_task = None
elif isinstance(frame, UserStoppedSpeakingFrame): elif isinstance(frame, UserStoppedSpeakingFrame):
# User stopped speaking - restart voicemail callback timer if voicemail detected # User stopped speaking - restart voicemail handler timer if voicemail detected
if self._voicemail_detected and not self._voicemail_callback_task: if self._voicemail_detected and not self._voicemail_handler_task:
self._voicemail_callback_task = self.create_task(self._delayed_voicemail_callback()) self._voicemail_handler_task = self.create_task(self._delayed_voicemail_handler())
else: else:
# Pass all non-LLM frames through # Pass all non-LLM frames through
@@ -348,36 +344,35 @@ class ClassificationProcessor(FrameProcessor):
# Interrupt the current pipeline to stop any ongoing processing # Interrupt the current pipeline to stop any ongoing processing
await self.push_frame(BotInterruptionFrame(), FrameDirection.UPSTREAM) await self.push_frame(BotInterruptionFrame(), FrameDirection.UPSTREAM)
# Always start the callback timer immediately # Always start the handler timer immediately
# It will be cancelled and restarted if user starts/stops speaking # It will be cancelled and restarted if user starts/stops speaking
if not self._voicemail_callback_task: if not self._voicemail_handler_task:
self._voicemail_callback_task = self.create_task(self._delayed_voicemail_callback()) self._voicemail_handler_task = self.create_task(self._delayed_voicemail_handler())
else: else:
# This can happen if the LLM is interrupted before completing the response # This can happen if the LLM is interrupted before completing the response
logger.debug(f"{self}: No classification found: '{full_response}'") logger.debug(f"{self}: No classification found: '{full_response}'")
async def _delayed_voicemail_callback(self): async def _delayed_voicemail_handler(self):
"""Execute the voicemail callback after the configured delay. """Execute the voicemail event handler after the configured delay.
This method waits for the specified delay period, then triggers the This method waits for the specified delay period, then triggers the
developer's voicemail callback. The timer can be cancelled and restarted developer's voicemail event handler. The timer can be cancelled and restarted
based on user speech patterns to ensure proper timing. based on user speech patterns to ensure proper timing.
""" """
try: try:
await asyncio.sleep(self._voicemail_response_delay) await asyncio.sleep(self._voicemail_response_delay)
if self._on_voicemail_detected: try:
try: logger.debug(f"{self}: Triggering voicemail detected event")
logger.debug(f"{self}: Executing voicemail callback") await self._call_event_handler("on_voicemail_detected")
await self._on_voicemail_detected(self) except Exception as e:
except Exception as e: logger.exception(f"{self}: Error in voicemail event handler: {e}")
logger.exception(f"{self}: Error in voicemail callback: {e}")
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
finally: finally:
self._voicemail_callback_task = None self._voicemail_handler_task = None
class TTSBuffer(FrameProcessor): class TTSBuffer(FrameProcessor):
@@ -482,7 +477,7 @@ class TTSBuffer(FrameProcessor):
When voicemail is detected, all buffered TTS frames are discarded When voicemail is detected, all buffered TTS frames are discarded
since they were intended for human conversation and are not appropriate since they were intended for human conversation and are not appropriate
for voicemail systems. The developer callback will handle voicemail- for voicemail systems. The developer event handlers will handle voicemail-
specific audio output. specific audio output.
""" """
try: try:
@@ -520,20 +515,17 @@ class VoicemailDetector(ParallelPipeline):
Once a decision is made, the appropriate action is taken: Once a decision is made, the appropriate action is taken:
- CONVERSATION: Continue normal bot dialogue - CONVERSATION: Continue normal bot dialogue
- VOICEMAIL: Trigger developer callback for custom voicemail handling - VOICEMAIL: Trigger developer event handler for custom voicemail handling
Example:: Example::
classification_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) classification_llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
detector = VoicemailDetector(llm=classification_llm)
@detector.event_handler("on_voicemail_detected")
async def handle_voicemail(processor): async def handle_voicemail(processor):
await processor.push_frame(TTSSpeakFrame("Please leave a message.")) await processor.push_frame(TTSSpeakFrame("Please leave a message."))
detector = VoicemailDetector(
llm=classification_llm,
on_voicemail_detected=handle_voicemail
)
pipeline = Pipeline([ pipeline = Pipeline([
transport.input(), transport.input(),
stt, stt,
@@ -545,6 +537,11 @@ class VoicemailDetector(ParallelPipeline):
transport.output(), transport.output(),
context_aggregator.assistant(), context_aggregator.assistant(),
]) ])
Events:
on_voicemail_detected: Triggered when voicemail is detected after the configured
delay. The event handler receives one argument: the ClassificationProcessor
instance which can be used to push frames.
""" """
DEFAULT_SYSTEM_PROMPT = """You are a voicemail detection classifier for an OUTBOUND calling system. A bot has called a phone number and you need to determine if a human answered or if the call went to voicemail based on the provided text. DEFAULT_SYSTEM_PROMPT = """You are a voicemail detection classifier for an OUTBOUND calling system. A bot has called a phone number and you need to determine if a human answered or if the call went to voicemail based on the provided text.
@@ -573,7 +570,6 @@ Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's vo
self, self,
*, *,
llm: LLMService, llm: LLMService,
on_voicemail_detected: Callable[["ClassificationProcessor"], Awaitable[None]],
voicemail_response_delay: float = 2.0, voicemail_response_delay: float = 2.0,
system_prompt: Optional[str] = None, system_prompt: Optional[str] = None,
): ):
@@ -582,11 +578,8 @@ Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's vo
Args: Args:
llm: LLM service used for voicemail vs conversation classification. llm: LLM service used for voicemail vs conversation classification.
Should be fast and reliable for real-time classification. Should be fast and reliable for real-time classification.
on_voicemail_detected: Optional callback function invoked when voicemail
is detected. Receives the ClassificationProcessor instance which can be
used to push frames (like custom voicemail greetings).
voicemail_response_delay: Delay in seconds after user stops speaking voicemail_response_delay: Delay in seconds after user stops speaking
before triggering the voicemail callback. This allows voicemail before triggering the voicemail event handler. This allows voicemail
responses to be played back after a short delay to ensure the response responses to be played back after a short delay to ensure the response
occurs during the voicemail recording. Default is 2.0 seconds. occurs during the voicemail recording. Default is 2.0 seconds.
system_prompt: Optional custom system prompt for classification. If None, system_prompt: Optional custom system prompt for classification. If None,
@@ -626,7 +619,6 @@ Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's vo
gate_notifier=self._gate_notifier, gate_notifier=self._gate_notifier,
conversation_notifier=self._conversation_notifier, conversation_notifier=self._conversation_notifier,
voicemail_notifier=self._voicemail_notifier, voicemail_notifier=self._voicemail_notifier,
on_voicemail_detected=on_voicemail_detected,
voicemail_response_delay=voicemail_response_delay, voicemail_response_delay=voicemail_response_delay,
) )
self._voicemail_buffer = TTSBuffer(self._conversation_notifier, self._voicemail_notifier) self._voicemail_buffer = TTSBuffer(self._conversation_notifier, self._voicemail_notifier)
@@ -645,6 +637,9 @@ Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's vo
], ],
) )
# Register the voicemail detected event after super().__init__()
self._register_event_handler("on_voicemail_detected")
def _validate_prompt(self, prompt: str) -> None: def _validate_prompt(self, prompt: str) -> None:
"""Validate custom prompt contains required response format instructions. """Validate custom prompt contains required response format instructions.
@@ -686,3 +681,15 @@ Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's vo
The TTSBuffer processor instance. The TTSBuffer processor instance.
""" """
return self._voicemail_buffer return self._voicemail_buffer
def add_event_handler(self, event_name: str, handler):
"""Add an event handler for voicemail detection events.
Args:
event_name: The name of the event to handle.
handler: The function to call when the event occurs.
"""
if event_name == "on_voicemail_detected":
self._classification_processor.add_event_handler(event_name, handler)
else:
super().add_event_handler(event_name, handler)