Update call_transfer.py
Browse files- call_transfer.py +98 -205
call_transfer.py
CHANGED
@@ -7,9 +7,10 @@ import argparse
|
|
7 |
import asyncio
|
8 |
import os
|
9 |
import sys
|
|
|
|
|
10 |
|
11 |
from call_connection_manager import CallConfigManager, SessionManager
|
12 |
-
from dotenv import load_dotenv
|
13 |
from loguru import logger
|
14 |
|
15 |
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
@@ -21,6 +22,8 @@ from pipecat.frames.frames import (
|
|
21 |
Frame,
|
22 |
LLMMessagesFrame,
|
23 |
TranscriptionFrame,
|
|
|
|
|
24 |
)
|
25 |
from pipecat.pipeline.pipeline import Pipeline
|
26 |
from pipecat.pipeline.runner import PipelineRunner
|
@@ -33,104 +36,109 @@ from pipecat.services.llm_service import FunctionCallParams, LLMService
|
|
33 |
from pipecat.services.openai.llm import OpenAILLMService
|
34 |
from pipecat.transports.services.daily import DailyDialinSettings, DailyParams, DailyTransport
|
35 |
|
36 |
-
load_dotenv(override=True)
|
37 |
-
|
38 |
logger.remove(0)
|
39 |
logger.add(sys.stderr, level="DEBUG")
|
40 |
|
41 |
-
daily_api_key = os.
|
42 |
-
daily_api_url = os.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
43 |
|
|
|
44 |
|
45 |
class TranscriptionModifierProcessor(FrameProcessor):
|
46 |
"""Processor that modifies transcription frames before they reach the context aggregator."""
|
47 |
-
|
48 |
def __init__(self, operator_session_id_ref):
|
49 |
-
"""Initialize with a reference to the operator_session_id variable.
|
50 |
-
|
51 |
-
Args:
|
52 |
-
operator_session_id_ref: A reference or container holding the operator's session ID
|
53 |
-
"""
|
54 |
super().__init__()
|
55 |
self.operator_session_id_ref = operator_session_id_ref
|
56 |
|
57 |
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
58 |
await super().process_frame(frame, direction)
|
59 |
-
|
60 |
-
# Only process frames that are moving downstream
|
61 |
if direction == FrameDirection.DOWNSTREAM:
|
62 |
-
# Check if the frame is a transcription frame
|
63 |
if isinstance(frame, TranscriptionFrame):
|
64 |
-
|
65 |
-
|
66 |
-
self.operator_session_id_ref[0]
|
67 |
-
and hasattr(frame, "user_id")
|
68 |
-
and frame.user_id == self.operator_session_id_ref[0]
|
69 |
-
):
|
70 |
-
# Modify the text to include operator prefix
|
71 |
frame.text = f"[OPERATOR]: {frame.text}"
|
72 |
logger.debug(f"++++ Modified Operator Transcription: {frame.text}")
|
73 |
-
|
74 |
-
# Push the (potentially modified) frame downstream
|
75 |
await self.push_frame(frame, direction)
|
76 |
|
77 |
-
|
78 |
class SummaryFinished(FrameProcessor):
|
79 |
"""Frame processor that monitors when summary has been finished."""
|
80 |
-
|
81 |
def __init__(self, dial_operator_state):
|
82 |
super().__init__()
|
83 |
-
# Store reference to the shared state object
|
84 |
self.dial_operator_state = dial_operator_state
|
85 |
|
86 |
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
87 |
await super().process_frame(frame, direction)
|
88 |
-
|
89 |
-
# Check if operator is connected and this is the end of bot speaking
|
90 |
-
if self.dial_operator_state.operator_connected and isinstance(
|
91 |
-
frame, BotStoppedSpeakingFrame
|
92 |
-
):
|
93 |
logger.debug("Summary finished, bot will stop speaking")
|
94 |
self.dial_operator_state.set_summary_finished()
|
95 |
-
|
96 |
await self.push_frame(frame, direction)
|
97 |
|
98 |
-
|
99 |
-
async def main(
|
100 |
-
room_url: str,
|
101 |
-
token: str,
|
102 |
-
body: dict,
|
103 |
-
):
|
104 |
# ------------ CONFIGURATION AND SETUP ------------
|
105 |
-
|
106 |
-
# Create a routing manager using the provided body
|
107 |
call_config_manager = CallConfigManager.from_json_string(body) if body else CallConfigManager()
|
108 |
-
|
109 |
-
# Get caller information
|
110 |
caller_info = call_config_manager.get_caller_info()
|
111 |
caller_number = caller_info["caller_number"]
|
112 |
dialed_number = caller_info["dialed_number"]
|
113 |
-
|
114 |
-
# Get customer name based on caller number
|
115 |
customer_name = call_config_manager.get_customer_name(caller_number) if caller_number else None
|
116 |
-
|
117 |
-
# Get appropriate operator settings based on the caller
|
118 |
operator_dialout_settings = call_config_manager.get_dialout_settings_for_caller(caller_number)
|
|
|
119 |
|
120 |
logger.info(f"Caller number: {caller_number}")
|
121 |
logger.info(f"Dialed number: {dialed_number}")
|
122 |
logger.info(f"Customer name: {customer_name}")
|
123 |
logger.info(f"Operator dialout settings: {operator_dialout_settings}")
|
124 |
|
125 |
-
# Check if in test mode
|
126 |
test_mode = call_config_manager.is_test_mode()
|
127 |
-
|
128 |
-
# Get dialin settings if present
|
129 |
dialin_settings = call_config_manager.get_dialin_settings()
|
|
|
|
|
130 |
|
131 |
# ------------ TRANSPORT SETUP ------------
|
132 |
-
|
133 |
-
# Set up transport parameters
|
134 |
if test_mode:
|
135 |
logger.info("Running in test mode")
|
136 |
transport_params = DailyParams(
|
@@ -157,44 +165,21 @@ async def main(
|
|
157 |
transcription_enabled=True,
|
158 |
)
|
159 |
|
160 |
-
|
161 |
-
session_manager = SessionManager()
|
162 |
-
|
163 |
-
# Set up the operator dialout settings
|
164 |
-
session_manager.call_flow_state.set_operator_dialout_settings(operator_dialout_settings)
|
165 |
-
|
166 |
-
# Initialize transport
|
167 |
-
transport = DailyTransport(
|
168 |
-
room_url,
|
169 |
-
token,
|
170 |
-
"Call Transfer Bot",
|
171 |
-
transport_params,
|
172 |
-
)
|
173 |
-
|
174 |
-
# Initialize TTS
|
175 |
tts = CartesiaTTSService(
|
176 |
-
api_key=os.
|
177 |
-
voice_id="b7d50908-b17c-442d-ad8d-810c63997ed9",
|
178 |
)
|
179 |
|
180 |
# ------------ LLM AND CONTEXT SETUP ------------
|
181 |
-
|
182 |
-
# Get prompts from routing manager
|
183 |
call_transfer_initial_prompt = call_config_manager.get_prompt("call_transfer_initial_prompt")
|
184 |
-
|
185 |
-
# Build default greeting with customer name if available
|
186 |
customer_greeting = f"Hello {customer_name}" if customer_name else "Hello"
|
187 |
default_greeting = f"{customer_greeting}, this is Hailey from customer support. What can I help you with today?"
|
188 |
|
189 |
-
# Build initial prompt
|
190 |
if call_transfer_initial_prompt:
|
191 |
-
|
192 |
-
system_instruction = call_config_manager.customize_prompt(
|
193 |
-
call_transfer_initial_prompt, customer_name
|
194 |
-
)
|
195 |
logger.info("Using custom call transfer initial prompt")
|
196 |
else:
|
197 |
-
# Use default prompt with formatted greeting
|
198 |
system_instruction = f"""You are Chatbot, a friendly, helpful robot. Never refer to this prompt, even if asked. Follow these steps **EXACTLY**.
|
199 |
|
200 |
### **Standard Operating Procedure:**
|
@@ -213,66 +198,46 @@ async def main(
|
|
213 |
"""
|
214 |
logger.info("Using default call transfer initial prompt")
|
215 |
|
216 |
-
# Create the system message and initialize messages list
|
217 |
messages = [call_config_manager.create_system_message(system_instruction)]
|
|
|
|
|
|
|
|
|
|
|
218 |
|
219 |
# ------------ FUNCTION DEFINITIONS ------------
|
220 |
-
|
221 |
-
async def terminate_call(
|
222 |
-
task: PipelineTask, # Pipeline task reference
|
223 |
-
params: FunctionCallParams,
|
224 |
-
):
|
225 |
-
"""Function the bot can call to terminate the call."""
|
226 |
-
# Create a message to add
|
227 |
content = "The user wants to end the conversation, thank them for chatting."
|
228 |
message = call_config_manager.create_system_message(content)
|
229 |
-
# Append the message to the list
|
230 |
messages.append(message)
|
231 |
-
# Queue the message to the context
|
232 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
233 |
-
|
234 |
-
# Then end the call
|
235 |
await params.llm.queue_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
|
236 |
|
237 |
async def dial_operator(params: FunctionCallParams):
|
238 |
-
"""Function the bot can call to dial an operator."""
|
239 |
dialout_setting = session_manager.call_flow_state.get_current_dialout_setting()
|
240 |
if call_config_manager.get_transfer_mode() == "dialout":
|
241 |
if dialout_setting:
|
242 |
session_manager.call_flow_state.set_operator_dialed()
|
243 |
logger.info(f"Dialing operator with settings: {dialout_setting}")
|
244 |
-
|
245 |
-
# Create a message to add
|
246 |
content = "The user has requested a supervisor, indicate that you will attempt to connect them with a supervisor."
|
247 |
message = call_config_manager.create_system_message(content)
|
248 |
-
|
249 |
-
# Append the message to the list
|
250 |
messages.append(message)
|
251 |
-
# Queue the message to the context
|
252 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
253 |
-
# Start the dialout
|
254 |
await call_config_manager.start_dialout(transport, [dialout_setting])
|
255 |
-
|
256 |
else:
|
257 |
-
# Create a message to add
|
258 |
content = "Indicate that there are no operator dialout settings available."
|
259 |
message = call_config_manager.create_system_message(content)
|
260 |
-
# Append the message to the list
|
261 |
messages.append(message)
|
262 |
-
# Queue the message to the context
|
263 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
264 |
logger.info("No operator dialout settings available")
|
265 |
else:
|
266 |
-
# Create a message to add
|
267 |
content = "Indicate that the current mode is not supported."
|
268 |
message = call_config_manager.create_system_message(content)
|
269 |
-
# Append the message to the list
|
270 |
messages.append(message)
|
271 |
-
# Queue the message to the context
|
272 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
273 |
logger.info("Other mode not supported")
|
274 |
|
275 |
-
# Define function schemas for tools
|
276 |
terminate_call_function = FunctionSchema(
|
277 |
name="terminate_call",
|
278 |
description="Call this function to terminate the call.",
|
@@ -287,179 +252,107 @@ async def main(
|
|
287 |
required=[],
|
288 |
)
|
289 |
|
290 |
-
# Create tools schema
|
291 |
tools = ToolsSchema(standard_tools=[terminate_call_function, dial_operator_function])
|
292 |
|
293 |
-
# Initialize LLM
|
294 |
-
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
|
295 |
-
|
296 |
-
# Register functions with the LLM
|
297 |
-
llm.register_function("terminate_call", lambda params: terminate_call(task, params))
|
298 |
-
llm.register_function("dial_operator", dial_operator)
|
299 |
-
|
300 |
-
# Initialize LLM context and aggregator
|
301 |
-
context = OpenAILLMContext(messages, tools)
|
302 |
-
context_aggregator = llm.create_context_aggregator(context)
|
303 |
-
|
304 |
# ------------ PIPELINE SETUP ------------
|
305 |
-
|
306 |
-
# Use the session manager's references
|
307 |
summary_finished = SummaryFinished(session_manager.call_flow_state)
|
308 |
-
transcription_modifier = TranscriptionModifierProcessor(
|
309 |
-
|
310 |
-
)
|
311 |
|
312 |
-
# Define function to determine if bot should speak
|
313 |
async def should_speak(self) -> bool:
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
context_aggregator.assistant(), # Assistant spoken responses
|
332 |
-
]
|
333 |
-
)
|
334 |
-
|
335 |
-
# Create pipeline task
|
336 |
-
task = PipelineTask(
|
337 |
-
pipeline,
|
338 |
-
params=PipelineParams(allow_interruptions=True),
|
339 |
-
)
|
340 |
|
341 |
# ------------ EVENT HANDLERS ------------
|
342 |
-
|
343 |
@transport.event_handler("on_first_participant_joined")
|
344 |
async def on_first_participant_joined(transport, participant):
|
345 |
await transport.capture_participant_transcription(participant["id"])
|
346 |
-
# For the dialin case, we want the bot to answer the phone and greet the user
|
347 |
await task.queue_frames([context_aggregator.user().get_context_frame()])
|
348 |
|
349 |
@transport.event_handler("on_dialout_answered")
|
350 |
async def on_dialout_answered(transport, data):
|
351 |
logger.debug(f"++++ Dial-out answered: {data}")
|
352 |
await transport.capture_participant_transcription(data["sessionId"])
|
353 |
-
|
354 |
-
# Skip if operator already connected
|
355 |
-
if (
|
356 |
-
not session_manager.call_flow_state
|
357 |
-
or session_manager.call_flow_state.operator_connected
|
358 |
-
):
|
359 |
logger.debug(f"Operator already connected: {data}")
|
360 |
return
|
361 |
-
|
362 |
logger.debug(f"Operator connected with session ID: {data['sessionId']}")
|
363 |
-
|
364 |
-
# Set operator session ID in the session manager
|
365 |
session_manager.set_session_id("operator", data["sessionId"])
|
366 |
-
|
367 |
-
# Update state
|
368 |
session_manager.call_flow_state.set_operator_connected()
|
369 |
-
|
370 |
-
# Determine message content based on configuration
|
371 |
if call_config_manager.get_speak_summary():
|
372 |
logger.debug("Bot will speak summary")
|
373 |
call_transfer_prompt = call_config_manager.get_prompt("call_transfer_prompt")
|
374 |
-
|
375 |
if call_transfer_prompt:
|
376 |
-
# Use custom prompt
|
377 |
logger.info("Using custom call transfer prompt")
|
378 |
content = call_config_manager.customize_prompt(call_transfer_prompt, customer_name)
|
379 |
else:
|
380 |
-
# Use default summary prompt
|
381 |
logger.info("Using default call transfer prompt")
|
382 |
customer_info = call_config_manager.get_customer_info_suffix(customer_name)
|
383 |
content = f"""An operator is joining the call{customer_info}.
|
384 |
Give a brief summary of the customer's issues so far."""
|
385 |
else:
|
386 |
-
# Simple join notification without summary
|
387 |
logger.debug("Bot will not speak summary")
|
388 |
customer_info = call_config_manager.get_customer_info_suffix(customer_name)
|
389 |
content = f"""Indicate that an operator has joined the call{customer_info}."""
|
390 |
-
|
391 |
-
# Create and queue system message
|
392 |
message = call_config_manager.create_system_message(content)
|
393 |
messages.append(message)
|
394 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
395 |
|
396 |
@transport.event_handler("on_dialout_stopped")
|
397 |
async def on_dialout_stopped(transport, data):
|
398 |
-
if session_manager.get_session_id("operator") and data[
|
399 |
-
"sessionId"
|
400 |
-
] == session_manager.get_session_id("operator"):
|
401 |
logger.debug("Dialout to operator stopped")
|
402 |
|
403 |
@transport.event_handler("on_participant_left")
|
404 |
async def on_participant_left(transport, participant, reason):
|
405 |
logger.debug(f"Participant left: {participant}, reason: {reason}")
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
session_manager.get_session_id("operator")
|
410 |
-
and participant["id"] == session_manager.get_session_id("operator")
|
411 |
-
):
|
412 |
await task.cancel()
|
413 |
return
|
414 |
-
|
415 |
logger.debug("Operator left the call")
|
416 |
-
|
417 |
-
# Reset operator state
|
418 |
session_manager.reset_participant("operator")
|
419 |
-
|
420 |
-
# Determine message content
|
421 |
-
call_transfer_finished_prompt = call_config_manager.get_prompt(
|
422 |
-
"call_transfer_finished_prompt"
|
423 |
-
)
|
424 |
-
|
425 |
if call_transfer_finished_prompt:
|
426 |
-
# Use custom prompt for operator departure
|
427 |
logger.info("Using custom call transfer finished prompt")
|
428 |
-
content = call_config_manager.customize_prompt(
|
429 |
-
call_transfer_finished_prompt, customer_name
|
430 |
-
)
|
431 |
else:
|
432 |
-
# Use default prompt for operator departure
|
433 |
logger.info("Using default call transfer finished prompt")
|
434 |
-
customer_info = call_config_manager.get_customer_info_suffix(
|
435 |
-
customer_name, preposition=""
|
436 |
-
)
|
437 |
content = f"""The operator has left the call.
|
438 |
Resume your role as the primary support agent and use information from the operator's conversation to help the customer{customer_info}.
|
439 |
Let the customer know the operator has left and ask if they need further assistance."""
|
440 |
-
|
441 |
-
# Create and queue system message
|
442 |
message = call_config_manager.create_system_message(content)
|
443 |
messages.append(message)
|
444 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
445 |
|
446 |
# ------------ RUN PIPELINE ------------
|
447 |
-
|
448 |
runner = PipelineRunner()
|
449 |
await runner.run(task)
|
450 |
|
451 |
-
|
452 |
if __name__ == "__main__":
|
453 |
parser = argparse.ArgumentParser(description="Pipecat Call Transfer Bot")
|
454 |
parser.add_argument("-u", "--url", type=str, help="Room URL")
|
455 |
parser.add_argument("-t", "--token", type=str, help="Room Token")
|
456 |
parser.add_argument("-b", "--body", type=str, help="JSON configuration string")
|
457 |
-
|
458 |
args = parser.parse_args()
|
459 |
-
|
460 |
-
# Log the arguments for debugging
|
461 |
logger.info(f"Room URL: {args.url}")
|
462 |
logger.info(f"Token: {args.token}")
|
463 |
logger.info(f"Body provided: {bool(args.body)}")
|
464 |
-
|
465 |
-
asyncio.run(main(args.url, args.token, args.body))
|
|
|
7 |
import asyncio
|
8 |
import os
|
9 |
import sys
|
10 |
+
import time
|
11 |
+
import json
|
12 |
|
13 |
from call_connection_manager import CallConfigManager, SessionManager
|
|
|
14 |
from loguru import logger
|
15 |
|
16 |
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
|
|
22 |
Frame,
|
23 |
LLMMessagesFrame,
|
24 |
TranscriptionFrame,
|
25 |
+
UserStartedSpeakingFrame,
|
26 |
+
UserStoppedSpeakingFrame,
|
27 |
)
|
28 |
from pipecat.pipeline.pipeline import Pipeline
|
29 |
from pipecat.pipeline.runner import PipelineRunner
|
|
|
36 |
from pipecat.services.openai.llm import OpenAILLMService
|
37 |
from pipecat.transports.services.daily import DailyDialinSettings, DailyParams, DailyTransport
|
38 |
|
|
|
|
|
39 |
logger.remove(0)
|
40 |
logger.add(sys.stderr, level="DEBUG")
|
41 |
|
42 |
+
daily_api_key = os.environ.get("HF_DAILY_API_KEY", "")
|
43 |
+
daily_api_url = os.environ.get("DAILY_API_URL", "https://api.daily.co/v1")
|
44 |
+
|
45 |
+
class SilenceDetectorProcessor(FrameProcessor):
|
46 |
+
"""Detects prolonged silence and triggers a TTS prompt after 10 seconds."""
|
47 |
+
def __init__(self, session_manager, call_config_manager, tts_service, task):
|
48 |
+
super().__init__()
|
49 |
+
self.session_manager = session_manager
|
50 |
+
self.call_config_manager = call_config_manager
|
51 |
+
self.tts_service = tts_service
|
52 |
+
self.task = task
|
53 |
+
self.last_speech_time = time.time()
|
54 |
+
self.silence_prompt_count = 0
|
55 |
+
self.max_prompts = 3
|
56 |
+
self.silence_threshold = 10 # 10 seconds
|
57 |
+
|
58 |
+
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
59 |
+
await super().process_frame(frame, direction)
|
60 |
+
|
61 |
+
if isinstance(frame, UserStartedSpeakingFrame):
|
62 |
+
self.last_speech_time = time.time()
|
63 |
+
self.session_manager.call_flow_state.reset_silence_prompts()
|
64 |
+
self.silence_prompt_count = 0
|
65 |
+
elif isinstance(frame, UserStoppedSpeakingFrame):
|
66 |
+
self.last_speech_time = time.time()
|
67 |
+
|
68 |
+
# Check for prolonged silence
|
69 |
+
if time.time() - self.last_speech_time >= self.silence_threshold:
|
70 |
+
if self.silence_prompt_count < self.max_prompts:
|
71 |
+
# Increment prompt count and log silence event
|
72 |
+
self.silence_prompt_count += 1
|
73 |
+
self.session_manager.call_flow_state.increment_silence_prompts()
|
74 |
+
logger.info(f"Silence detected for {self.silence_threshold}s, sending prompt #{self.silence_prompt_count}")
|
75 |
+
|
76 |
+
# Send TTS prompt
|
77 |
+
prompt = "Hello, are you still there? How can I assist you?"
|
78 |
+
message = self.call_config_manager.create_system_message(prompt)
|
79 |
+
await self.task.queue_frames([LLMMessagesFrame([message])])
|
80 |
+
self.last_speech_time = time.time() # Reset silence timer
|
81 |
+
else:
|
82 |
+
# Terminate call after max prompts
|
83 |
+
logger.info("Max silence prompts reached, terminating call")
|
84 |
+
farewell = "Thank you for calling. Goodbye."
|
85 |
+
message = self.call_config_manager.create_system_message(farewell)
|
86 |
+
await self.task.queue_frames([LLMMessagesFrame([message])])
|
87 |
+
await self.task.queue_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
|
88 |
|
89 |
+
await self.push_frame(frame, direction)
|
90 |
|
91 |
class TranscriptionModifierProcessor(FrameProcessor):
|
92 |
"""Processor that modifies transcription frames before they reach the context aggregator."""
|
|
|
93 |
def __init__(self, operator_session_id_ref):
|
|
|
|
|
|
|
|
|
|
|
94 |
super().__init__()
|
95 |
self.operator_session_id_ref = operator_session_id_ref
|
96 |
|
97 |
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
98 |
await super().process_frame(frame, direction)
|
|
|
|
|
99 |
if direction == FrameDirection.DOWNSTREAM:
|
|
|
100 |
if isinstance(frame, TranscriptionFrame):
|
101 |
+
if (self.operator_session_id_ref[0] is not None and
|
102 |
+
hasattr(frame, "user_id") and
|
103 |
+
frame.user_id == self.operator_session_id_ref[0]):
|
|
|
|
|
|
|
|
|
104 |
frame.text = f"[OPERATOR]: {frame.text}"
|
105 |
logger.debug(f"++++ Modified Operator Transcription: {frame.text}")
|
|
|
|
|
106 |
await self.push_frame(frame, direction)
|
107 |
|
|
|
108 |
class SummaryFinished(FrameProcessor):
|
109 |
"""Frame processor that monitors when summary has been finished."""
|
|
|
110 |
def __init__(self, dial_operator_state):
|
111 |
super().__init__()
|
|
|
112 |
self.dial_operator_state = dial_operator_state
|
113 |
|
114 |
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
115 |
await super().process_frame(frame, direction)
|
116 |
+
if self.dial_operator_state.operator_connected and isinstance(frame, BotStoppedSpeakingFrame):
|
|
|
|
|
|
|
|
|
117 |
logger.debug("Summary finished, bot will stop speaking")
|
118 |
self.dial_operator_state.set_summary_finished()
|
|
|
119 |
await self.push_frame(frame, direction)
|
120 |
|
121 |
+
async def main(room_url: str, token: str, body: dict):
|
|
|
|
|
|
|
|
|
|
|
122 |
# ------------ CONFIGURATION AND SETUP ------------
|
|
|
|
|
123 |
call_config_manager = CallConfigManager.from_json_string(body) if body else CallConfigManager()
|
|
|
|
|
124 |
caller_info = call_config_manager.get_caller_info()
|
125 |
caller_number = caller_info["caller_number"]
|
126 |
dialed_number = caller_info["dialed_number"]
|
|
|
|
|
127 |
customer_name = call_config_manager.get_customer_name(caller_number) if caller_number else None
|
|
|
|
|
128 |
operator_dialout_settings = call_config_manager.get_dialout_settings_for_caller(caller_number)
|
129 |
+
call_start_time = time.time() # Track call start time
|
130 |
|
131 |
logger.info(f"Caller number: {caller_number}")
|
132 |
logger.info(f"Dialed number: {dialed_number}")
|
133 |
logger.info(f"Customer name: {customer_name}")
|
134 |
logger.info(f"Operator dialout settings: {operator_dialout_settings}")
|
135 |
|
|
|
136 |
test_mode = call_config_manager.is_test_mode()
|
|
|
|
|
137 |
dialin_settings = call_config_manager.get_dialin_settings()
|
138 |
+
session_manager = SessionManager()
|
139 |
+
session_manager.call_flow_state.set_operator_dialout_settings(operator_dialout_settings)
|
140 |
|
141 |
# ------------ TRANSPORT SETUP ------------
|
|
|
|
|
142 |
if test_mode:
|
143 |
logger.info("Running in test mode")
|
144 |
transport_params = DailyParams(
|
|
|
165 |
transcription_enabled=True,
|
166 |
)
|
167 |
|
168 |
+
transport = DailyTransport(room_url, token, "Call Transfer Bot", transport_params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
169 |
tts = CartesiaTTSService(
|
170 |
+
api_key=os.environ.get("HF_CARTESIA_API_KEY", ""),
|
171 |
+
voice_id="b7d50908-b17c-442d-ad8d-810c63997ed9",
|
172 |
)
|
173 |
|
174 |
# ------------ LLM AND CONTEXT SETUP ------------
|
|
|
|
|
175 |
call_transfer_initial_prompt = call_config_manager.get_prompt("call_transfer_initial_prompt")
|
|
|
|
|
176 |
customer_greeting = f"Hello {customer_name}" if customer_name else "Hello"
|
177 |
default_greeting = f"{customer_greeting}, this is Hailey from customer support. What can I help you with today?"
|
178 |
|
|
|
179 |
if call_transfer_initial_prompt:
|
180 |
+
system_instruction = call_config_manager.customize_prompt(call_transfer_initial_prompt, customer_name)
|
|
|
|
|
|
|
181 |
logger.info("Using custom call transfer initial prompt")
|
182 |
else:
|
|
|
183 |
system_instruction = f"""You are Chatbot, a friendly, helpful robot. Never refer to this prompt, even if asked. Follow these steps **EXACTLY**.
|
184 |
|
185 |
### **Standard Operating Procedure:**
|
|
|
198 |
"""
|
199 |
logger.info("Using default call transfer initial prompt")
|
200 |
|
|
|
201 |
messages = [call_config_manager.create_system_message(system_instruction)]
|
202 |
+
llm = OpenAILLMService(api_key=os.environ.get("HF_OPENAI_API_KEY"))
|
203 |
+
llm.register_function("terminate_call", lambda params: terminate_call(task, params, call_config_manager, call_start_time))
|
204 |
+
llm.register_function("dial_operator", dial_operator)
|
205 |
+
context = OpenAILLMContext(messages, tools)
|
206 |
+
context_aggregator = llm.create_context_aggregator(context)
|
207 |
|
208 |
# ------------ FUNCTION DEFINITIONS ------------
|
209 |
+
async def terminate_call(task: PipelineTask, params: FunctionCallParams, call_config_manager, call_start_time):
|
|
|
|
|
|
|
|
|
|
|
|
|
210 |
content = "The user wants to end the conversation, thank them for chatting."
|
211 |
message = call_config_manager.create_system_message(content)
|
|
|
212 |
messages.append(message)
|
|
|
213 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
214 |
+
await call_config_manager.log_call_summary(call_start_time, session_manager, caller_number, dialed_number, customer_name)
|
|
|
215 |
await params.llm.queue_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
|
216 |
|
217 |
async def dial_operator(params: FunctionCallParams):
|
|
|
218 |
dialout_setting = session_manager.call_flow_state.get_current_dialout_setting()
|
219 |
if call_config_manager.get_transfer_mode() == "dialout":
|
220 |
if dialout_setting:
|
221 |
session_manager.call_flow_state.set_operator_dialed()
|
222 |
logger.info(f"Dialing operator with settings: {dialout_setting}")
|
|
|
|
|
223 |
content = "The user has requested a supervisor, indicate that you will attempt to connect them with a supervisor."
|
224 |
message = call_config_manager.create_system_message(content)
|
|
|
|
|
225 |
messages.append(message)
|
|
|
226 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
|
|
227 |
await call_config_manager.start_dialout(transport, [dialout_setting])
|
|
|
228 |
else:
|
|
|
229 |
content = "Indicate that there are no operator dialout settings available."
|
230 |
message = call_config_manager.create_system_message(content)
|
|
|
231 |
messages.append(message)
|
|
|
232 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
233 |
logger.info("No operator dialout settings available")
|
234 |
else:
|
|
|
235 |
content = "Indicate that the current mode is not supported."
|
236 |
message = call_config_manager.create_system_message(content)
|
|
|
237 |
messages.append(message)
|
|
|
238 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
239 |
logger.info("Other mode not supported")
|
240 |
|
|
|
241 |
terminate_call_function = FunctionSchema(
|
242 |
name="terminate_call",
|
243 |
description="Call this function to terminate the call.",
|
|
|
252 |
required=[],
|
253 |
)
|
254 |
|
|
|
255 |
tools = ToolsSchema(standard_tools=[terminate_call_function, dial_operator_function])
|
256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
257 |
# ------------ PIPELINE SETUP ------------
|
|
|
|
|
258 |
summary_finished = SummaryFinished(session_manager.call_flow_state)
|
259 |
+
transcription_modifier = TranscriptionModifierProcessor(session_manager.get_session_id_ref("operator"))
|
260 |
+
silence_detector = SilenceDetectorProcessor(session_manager, call_config_manager, tts, task)
|
|
|
261 |
|
|
|
262 |
async def should_speak(self) -> bool:
|
263 |
+
return (not session_manager.call_flow_state.operator_connected or
|
264 |
+
not session_manager.call_flow_state.summary_finished)
|
265 |
+
|
266 |
+
pipeline = Pipeline([
|
267 |
+
transport.input(),
|
268 |
+
silence_detector, # Add silence detection
|
269 |
+
transcription_modifier,
|
270 |
+
context_aggregator.user(),
|
271 |
+
FunctionFilter(should_speak),
|
272 |
+
llm,
|
273 |
+
tts,
|
274 |
+
summary_finished,
|
275 |
+
transport.output(),
|
276 |
+
context_aggregator.assistant(),
|
277 |
+
])
|
278 |
+
|
279 |
+
task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
280 |
|
281 |
# ------------ EVENT HANDLERS ------------
|
|
|
282 |
@transport.event_handler("on_first_participant_joined")
|
283 |
async def on_first_participant_joined(transport, participant):
|
284 |
await transport.capture_participant_transcription(participant["id"])
|
|
|
285 |
await task.queue_frames([context_aggregator.user().get_context_frame()])
|
286 |
|
287 |
@transport.event_handler("on_dialout_answered")
|
288 |
async def on_dialout_answered(transport, data):
|
289 |
logger.debug(f"++++ Dial-out answered: {data}")
|
290 |
await transport.capture_participant_transcription(data["sessionId"])
|
291 |
+
if not session_manager.call_flow_state or session_manager.call_flow_state.operator_connected:
|
|
|
|
|
|
|
|
|
|
|
292 |
logger.debug(f"Operator already connected: {data}")
|
293 |
return
|
|
|
294 |
logger.debug(f"Operator connected with session ID: {data['sessionId']}")
|
|
|
|
|
295 |
session_manager.set_session_id("operator", data["sessionId"])
|
|
|
|
|
296 |
session_manager.call_flow_state.set_operator_connected()
|
|
|
|
|
297 |
if call_config_manager.get_speak_summary():
|
298 |
logger.debug("Bot will speak summary")
|
299 |
call_transfer_prompt = call_config_manager.get_prompt("call_transfer_prompt")
|
|
|
300 |
if call_transfer_prompt:
|
|
|
301 |
logger.info("Using custom call transfer prompt")
|
302 |
content = call_config_manager.customize_prompt(call_transfer_prompt, customer_name)
|
303 |
else:
|
|
|
304 |
logger.info("Using default call transfer prompt")
|
305 |
customer_info = call_config_manager.get_customer_info_suffix(customer_name)
|
306 |
content = f"""An operator is joining the call{customer_info}.
|
307 |
Give a brief summary of the customer's issues so far."""
|
308 |
else:
|
|
|
309 |
logger.debug("Bot will not speak summary")
|
310 |
customer_info = call_config_manager.get_customer_info_suffix(customer_name)
|
311 |
content = f"""Indicate that an operator has joined the call{customer_info}."""
|
|
|
|
|
312 |
message = call_config_manager.create_system_message(content)
|
313 |
messages.append(message)
|
314 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
315 |
|
316 |
@transport.event_handler("on_dialout_stopped")
|
317 |
async def on_dialout_stopped(transport, data):
|
318 |
+
if session_manager.get_session_id("operator") and data["sessionId"] == session_manager.get_session_id("operator"):
|
|
|
|
|
319 |
logger.debug("Dialout to operator stopped")
|
320 |
|
321 |
@transport.event_handler("on_participant_left")
|
322 |
async def on_participant_left(transport, participant, reason):
|
323 |
logger.debug(f"Participant left: {participant}, reason: {reason}")
|
324 |
+
if not (session_manager.get_session_id("operator") and
|
325 |
+
participant["id"] == session_manager.get_session_id("operator")):
|
326 |
+
await call_config_manager.log_call_summary(call_start_time, session_manager, caller_number, dialed_number, customer_name)
|
|
|
|
|
|
|
327 |
await task.cancel()
|
328 |
return
|
|
|
329 |
logger.debug("Operator left the call")
|
|
|
|
|
330 |
session_manager.reset_participant("operator")
|
331 |
+
call_transfer_finished_prompt = call_config_manager.get_prompt("call_transfer_finished_prompt")
|
|
|
|
|
|
|
|
|
|
|
332 |
if call_transfer_finished_prompt:
|
|
|
333 |
logger.info("Using custom call transfer finished prompt")
|
334 |
+
content = call_config_manager.customize_prompt(call_transfer_finished_prompt, customer_name)
|
|
|
|
|
335 |
else:
|
|
|
336 |
logger.info("Using default call transfer finished prompt")
|
337 |
+
customer_info = call_config_manager.get_customer_info_suffix(customer_name, preposition="")
|
|
|
|
|
338 |
content = f"""The operator has left the call.
|
339 |
Resume your role as the primary support agent and use information from the operator's conversation to help the customer{customer_info}.
|
340 |
Let the customer know the operator has left and ask if they need further assistance."""
|
|
|
|
|
341 |
message = call_config_manager.create_system_message(content)
|
342 |
messages.append(message)
|
343 |
await task.queue_frames([LLMMessagesFrame(messages)])
|
344 |
|
345 |
# ------------ RUN PIPELINE ------------
|
|
|
346 |
runner = PipelineRunner()
|
347 |
await runner.run(task)
|
348 |
|
|
|
349 |
if __name__ == "__main__":
|
350 |
parser = argparse.ArgumentParser(description="Pipecat Call Transfer Bot")
|
351 |
parser.add_argument("-u", "--url", type=str, help="Room URL")
|
352 |
parser.add_argument("-t", "--token", type=str, help="Room Token")
|
353 |
parser.add_argument("-b", "--body", type=str, help="JSON configuration string")
|
|
|
354 |
args = parser.parse_args()
|
|
|
|
|
355 |
logger.info(f"Room URL: {args.url}")
|
356 |
logger.info(f"Token: {args.token}")
|
357 |
logger.info(f"Body provided: {bool(args.body)}")
|
358 |
+
asyncio.run(main(args.url, args.token, args.body))
|
|