Update call_connection_manager.py
Browse files- call_connection_manager.py +51 -351
call_connection_manager.py
CHANGED
@@ -3,269 +3,141 @@
|
|
3 |
#
|
4 |
# SPDX-License-Identifier: BSD 2-Clause License
|
5 |
#
|
6 |
-
"""call_connection_manager.py.
|
7 |
-
|
8 |
-
Manages customer/operator relationships and call routing for voice bots.
|
9 |
-
Provides mapping between customers and operators, and functions for retrieving
|
10 |
-
contact information. Also includes call state management.
|
11 |
-
"""
|
12 |
-
|
13 |
import json
|
14 |
import os
|
|
|
15 |
from typing import Any, Dict, List, Optional
|
16 |
-
|
17 |
from loguru import logger
|
18 |
|
19 |
-
|
20 |
class CallFlowState:
|
21 |
-
"""State for tracking call flow operations and state transitions."""
|
22 |
-
|
23 |
def __init__(self):
|
24 |
-
# Operator-related state
|
25 |
self.dialed_operator = False
|
26 |
self.operator_connected = False
|
27 |
self.current_operator_index = 0
|
28 |
self.operator_dialout_settings = []
|
29 |
self.summary_finished = False
|
30 |
-
|
31 |
-
# Voicemail detection state
|
32 |
self.voicemail_detected = False
|
33 |
self.human_detected = False
|
34 |
self.voicemail_message_left = False
|
35 |
-
|
36 |
-
# Call termination state
|
37 |
self.call_terminated = False
|
38 |
self.participant_left_early = False
|
|
|
39 |
|
40 |
-
# Operator-related methods
|
41 |
def set_operator_dialed(self):
|
42 |
-
"""Mark that an operator has been dialed."""
|
43 |
self.dialed_operator = True
|
44 |
|
45 |
def set_operator_connected(self):
|
46 |
-
"""Mark that an operator has connected to the call."""
|
47 |
self.operator_connected = True
|
48 |
-
# Summary is not finished when operator first connects
|
49 |
self.summary_finished = False
|
50 |
|
51 |
def set_operator_disconnected(self):
|
52 |
-
"""Handle operator disconnection."""
|
53 |
self.operator_connected = False
|
54 |
self.summary_finished = False
|
55 |
|
56 |
def set_summary_finished(self):
|
57 |
-
"""Mark the summary as finished."""
|
58 |
self.summary_finished = True
|
59 |
|
60 |
def set_operator_dialout_settings(self, settings):
|
61 |
-
"""Set the list of operator dialout settings to try."""
|
62 |
self.operator_dialout_settings = settings
|
63 |
self.current_operator_index = 0
|
64 |
|
65 |
def get_current_dialout_setting(self):
|
66 |
-
|
67 |
-
if not self.operator_dialout_settings or self.current_operator_index >= len(
|
68 |
-
self.operator_dialout_settings
|
69 |
-
):
|
70 |
return None
|
71 |
return self.operator_dialout_settings[self.current_operator_index]
|
72 |
|
73 |
def move_to_next_operator(self):
|
74 |
-
"""Move to the next operator in the list."""
|
75 |
self.current_operator_index += 1
|
76 |
return self.get_current_dialout_setting()
|
77 |
|
78 |
-
# Voicemail detection methods
|
79 |
def set_voicemail_detected(self):
|
80 |
-
"""Mark that a voicemail system has been detected."""
|
81 |
self.voicemail_detected = True
|
82 |
self.human_detected = False
|
83 |
|
84 |
def set_human_detected(self):
|
85 |
-
"""Mark that a human has been detected (not voicemail)."""
|
86 |
self.human_detected = True
|
87 |
self.voicemail_detected = False
|
88 |
|
89 |
def set_voicemail_message_left(self):
|
90 |
-
"""Mark that a voicemail message has been left."""
|
91 |
self.voicemail_message_left = True
|
92 |
|
93 |
-
# Call termination methods
|
94 |
def set_call_terminated(self):
|
95 |
-
"""Mark that the call has been terminated by the bot."""
|
96 |
self.call_terminated = True
|
97 |
|
98 |
def set_participant_left_early(self):
|
99 |
-
"""Mark that a participant left the call early."""
|
100 |
self.participant_left_early = True
|
101 |
|
|
|
|
|
102 |
|
103 |
-
|
104 |
-
|
105 |
|
|
|
106 |
def __init__(self):
|
107 |
-
|
108 |
-
self.
|
109 |
-
"operator": None,
|
110 |
-
"customer": None,
|
111 |
-
"bot": None,
|
112 |
-
# Add other participant types as needed
|
113 |
-
}
|
114 |
-
|
115 |
-
# References for easy access in processors that need mutable containers
|
116 |
-
self.session_id_refs = {
|
117 |
-
"operator": [None],
|
118 |
-
"customer": [None],
|
119 |
-
"bot": [None],
|
120 |
-
# Add other participant types as needed
|
121 |
-
}
|
122 |
-
|
123 |
-
# State object for call flow
|
124 |
self.call_flow_state = CallFlowState()
|
125 |
|
126 |
def set_session_id(self, participant_type, session_id):
|
127 |
-
"""Set the session ID for a specific participant type.
|
128 |
-
|
129 |
-
Args:
|
130 |
-
participant_type: Type of participant (e.g., "operator", "customer", "bot")
|
131 |
-
session_id: The session ID to set
|
132 |
-
"""
|
133 |
if participant_type in self.session_ids:
|
134 |
self.session_ids[participant_type] = session_id
|
135 |
-
|
136 |
-
# Also update the corresponding reference if it exists
|
137 |
if participant_type in self.session_id_refs:
|
138 |
self.session_id_refs[participant_type][0] = session_id
|
139 |
|
140 |
def get_session_id(self, participant_type):
|
141 |
-
"""Get the session ID for a specific participant type.
|
142 |
-
|
143 |
-
Args:
|
144 |
-
participant_type: Type of participant (e.g., "operator", "customer", "bot")
|
145 |
-
|
146 |
-
Returns:
|
147 |
-
The session ID or None if not set
|
148 |
-
"""
|
149 |
return self.session_ids.get(participant_type)
|
150 |
|
151 |
def get_session_id_ref(self, participant_type):
|
152 |
-
"""Get the mutable reference for a specific participant type.
|
153 |
-
|
154 |
-
Args:
|
155 |
-
participant_type: Type of participant (e.g., "operator", "customer", "bot")
|
156 |
-
|
157 |
-
Returns:
|
158 |
-
A mutable list container holding the session ID or None if not available
|
159 |
-
"""
|
160 |
return self.session_id_refs.get(participant_type)
|
161 |
|
162 |
def is_participant_type(self, session_id, participant_type):
|
163 |
-
"""Check if a session ID belongs to a specific participant type.
|
164 |
-
|
165 |
-
Args:
|
166 |
-
session_id: The session ID to check
|
167 |
-
participant_type: Type of participant (e.g., "operator", "customer", "bot")
|
168 |
-
|
169 |
-
Returns:
|
170 |
-
True if the session ID matches the participant type, False otherwise
|
171 |
-
"""
|
172 |
return self.session_ids.get(participant_type) == session_id
|
173 |
|
174 |
def reset_participant(self, participant_type):
|
175 |
-
"""Reset the state for a specific participant type.
|
176 |
-
|
177 |
-
Args:
|
178 |
-
participant_type: Type of participant (e.g., "operator", "customer", "bot")
|
179 |
-
"""
|
180 |
if participant_type in self.session_ids:
|
181 |
self.session_ids[participant_type] = None
|
182 |
-
|
183 |
if participant_type in self.session_id_refs:
|
184 |
self.session_id_refs[participant_type][0] = None
|
185 |
-
|
186 |
-
# Additional reset actions for specific participant types
|
187 |
if participant_type == "operator":
|
188 |
self.call_flow_state.set_operator_disconnected()
|
189 |
|
190 |
-
|
191 |
class CallConfigManager:
|
192 |
-
"""Manages customer/operator relationships and call routing."""
|
193 |
-
|
194 |
def __init__(self, body_data: Dict[str, Any] = None):
|
195 |
-
"""Initialize with optional body data.
|
196 |
-
|
197 |
-
Args:
|
198 |
-
body_data: Optional dictionary containing request body data
|
199 |
-
"""
|
200 |
self.body = body_data or {}
|
201 |
-
|
202 |
-
|
203 |
-
self.
|
204 |
-
self.dial_out_to_number = os.getenv("DIAL_OUT_TO_NUMBER", "+10000000002")
|
205 |
-
self.operator_number = os.getenv("OPERATOR_NUMBER", "+10000000003")
|
206 |
-
|
207 |
-
# Initialize maps with dynamic values
|
208 |
self._initialize_maps()
|
209 |
self._build_reverse_lookup_maps()
|
210 |
|
211 |
def _initialize_maps(self):
|
212 |
-
"""Initialize the customer and operator maps with environment variables."""
|
213 |
-
# Maps customer names to their contact information
|
214 |
self.CUSTOMER_MAP = {
|
215 |
-
"Dominic": {
|
216 |
-
|
217 |
-
},
|
218 |
-
"
|
219 |
-
|
220 |
-
},
|
221 |
-
"James": {
|
222 |
-
"phoneNumber": "+10000000000",
|
223 |
-
"callerId": "james-caller-id-uuid",
|
224 |
-
"sipUri": "sip:[email protected]",
|
225 |
-
},
|
226 |
-
"Sarah": {
|
227 |
-
"sipUri": "sip:[email protected]",
|
228 |
-
},
|
229 |
-
"Michael": {
|
230 |
-
"phoneNumber": "+16505557890",
|
231 |
-
"callerId": "michael-caller-id-uuid",
|
232 |
-
},
|
233 |
}
|
234 |
-
|
235 |
-
# Maps customer names to their assigned operator names
|
236 |
self.CUSTOMER_TO_OPERATOR_MAP = {
|
237 |
-
"Dominic": ["Yunyoung", "Maria"],
|
238 |
"Stewart": "Yunyoung",
|
239 |
"James": "Yunyoung",
|
240 |
"Sarah": "Jennifer",
|
241 |
"Michael": "Paul",
|
242 |
-
# Default mapping to ensure all customers have an operator
|
243 |
"Default": "Yunyoung",
|
244 |
}
|
245 |
-
|
246 |
-
# Maps operator names to their contact details
|
247 |
self.OPERATOR_CONTACT_MAP = {
|
248 |
-
"Paul": {
|
249 |
-
|
250 |
-
|
251 |
-
},
|
252 |
-
"Yunyoung": {
|
253 |
-
"phoneNumber": self.operator_number, # Dials out to my other phone number.
|
254 |
-
},
|
255 |
-
"Maria": {
|
256 |
-
"sipUri": "sip:[email protected]",
|
257 |
-
},
|
258 |
"Jennifer": {"phoneNumber": "+14155559876", "callerId": "jennifer-caller-id-uuid"},
|
259 |
-
"Default": {
|
260 |
-
"phoneNumber": self.operator_number, # Use the operator number as default
|
261 |
-
},
|
262 |
}
|
263 |
|
264 |
def _build_reverse_lookup_maps(self):
|
265 |
-
"""Build reverse lookup maps for phone numbers and SIP URIs to customer names."""
|
266 |
self._PHONE_TO_CUSTOMER_MAP = {}
|
267 |
self._SIP_TO_CUSTOMER_MAP = {}
|
268 |
-
|
269 |
for customer_name, contact_info in self.CUSTOMER_MAP.items():
|
270 |
if "phoneNumber" in contact_info:
|
271 |
self._PHONE_TO_CUSTOMER_MAP[contact_info["phoneNumber"]] = customer_name
|
@@ -274,158 +146,59 @@ class CallConfigManager:
|
|
274 |
|
275 |
@classmethod
|
276 |
def from_json_string(cls, json_string: str):
|
277 |
-
"""Create a CallRoutingManager from a JSON string.
|
278 |
-
|
279 |
-
Args:
|
280 |
-
json_string: JSON string containing body data
|
281 |
-
|
282 |
-
Returns:
|
283 |
-
CallRoutingManager instance with parsed data
|
284 |
-
|
285 |
-
Raises:
|
286 |
-
json.JSONDecodeError: If JSON string is invalid
|
287 |
-
"""
|
288 |
body_data = json.loads(json_string)
|
289 |
return cls(body_data)
|
290 |
|
291 |
def find_customer_by_contact(self, contact_info: str) -> Optional[str]:
|
292 |
-
"""Find customer name from a contact identifier (phone number or SIP URI).
|
293 |
-
|
294 |
-
Args:
|
295 |
-
contact_info: The contact identifier (phone number or SIP URI)
|
296 |
-
|
297 |
-
Returns:
|
298 |
-
The customer name or None if not found
|
299 |
-
"""
|
300 |
-
# Check if it's a phone number
|
301 |
if contact_info in self._PHONE_TO_CUSTOMER_MAP:
|
302 |
return self._PHONE_TO_CUSTOMER_MAP[contact_info]
|
303 |
-
|
304 |
-
# Check if it's a SIP URI
|
305 |
if contact_info in self._SIP_TO_CUSTOMER_MAP:
|
306 |
return self._SIP_TO_CUSTOMER_MAP[contact_info]
|
307 |
-
|
308 |
return None
|
309 |
|
310 |
def get_customer_name(self, phone_number: str) -> Optional[str]:
|
311 |
-
"""Get customer name from their phone number.
|
312 |
-
|
313 |
-
Args:
|
314 |
-
phone_number: The customer's phone number
|
315 |
-
|
316 |
-
Returns:
|
317 |
-
The customer name or None if not found
|
318 |
-
"""
|
319 |
-
# Note: In production, this would likely query a database
|
320 |
return self.find_customer_by_contact(phone_number)
|
321 |
|
322 |
def get_operators_for_customer(self, customer_name: Optional[str]) -> List[str]:
|
323 |
-
"""Get the operator name(s) assigned to a customer.
|
324 |
-
|
325 |
-
Args:
|
326 |
-
customer_name: The customer's name
|
327 |
-
|
328 |
-
Returns:
|
329 |
-
List of operator names (single item or multiple)
|
330 |
-
"""
|
331 |
-
# Note: In production, this would likely query a database
|
332 |
if not customer_name or customer_name not in self.CUSTOMER_TO_OPERATOR_MAP:
|
333 |
return ["Default"]
|
334 |
-
|
335 |
operators = self.CUSTOMER_TO_OPERATOR_MAP[customer_name]
|
336 |
-
# Convert single string to list for consistency
|
337 |
if isinstance(operators, str):
|
338 |
return [operators]
|
339 |
return operators
|
340 |
|
341 |
def get_operator_dialout_settings(self, operator_name: str) -> Dict[str, str]:
|
342 |
-
"""Get an operator's dialout settings from their name.
|
343 |
-
|
344 |
-
Args:
|
345 |
-
operator_name: The operator's name
|
346 |
-
|
347 |
-
Returns:
|
348 |
-
Dictionary with dialout settings for the operator
|
349 |
-
"""
|
350 |
-
# Note: In production, this would likely query a database
|
351 |
return self.OPERATOR_CONTACT_MAP.get(operator_name, self.OPERATOR_CONTACT_MAP["Default"])
|
352 |
|
353 |
-
def get_dialout_settings_for_caller(
|
354 |
-
self, from_number: Optional[str] = None
|
355 |
-
) -> List[Dict[str, str]]:
|
356 |
-
"""Determine the appropriate operator dialout settings based on caller's number.
|
357 |
-
|
358 |
-
This method uses the caller's number to look up the customer name,
|
359 |
-
then finds the assigned operators for that customer, and returns
|
360 |
-
an array of operator dialout settings to try in sequence.
|
361 |
-
|
362 |
-
Args:
|
363 |
-
from_number: The caller's phone number (from dialin_settings)
|
364 |
-
|
365 |
-
Returns:
|
366 |
-
List of operator dialout settings to try
|
367 |
-
"""
|
368 |
if not from_number:
|
369 |
-
# If we don't have dialin settings, use the Default operator
|
370 |
return [self.get_operator_dialout_settings("Default")]
|
371 |
-
|
372 |
-
# Get customer name from phone number
|
373 |
customer_name = self.get_customer_name(from_number)
|
374 |
-
|
375 |
-
# Get operator names assigned to this customer
|
376 |
operator_names = self.get_operators_for_customer(customer_name)
|
377 |
-
|
378 |
-
# Get dialout settings for each operator
|
379 |
return [self.get_operator_dialout_settings(name) for name in operator_names]
|
380 |
|
381 |
def get_caller_info(self) -> Dict[str, Optional[str]]:
|
382 |
-
"""Get caller and dialed numbers from dialin settings in the body.
|
383 |
-
|
384 |
-
Returns:
|
385 |
-
Dictionary containing caller_number and dialed_number
|
386 |
-
"""
|
387 |
raw_dialin_settings = self.body.get("dialin_settings")
|
388 |
if not raw_dialin_settings:
|
389 |
return {"caller_number": None, "dialed_number": None}
|
390 |
-
|
391 |
-
# Handle different case variations
|
392 |
dialed_number = raw_dialin_settings.get("To") or raw_dialin_settings.get("to")
|
393 |
caller_number = raw_dialin_settings.get("From") or raw_dialin_settings.get("from")
|
394 |
-
|
395 |
return {"caller_number": caller_number, "dialed_number": dialed_number}
|
396 |
|
397 |
def get_caller_number(self) -> Optional[str]:
|
398 |
-
"""Get the caller's phone number from dialin settings in the body.
|
399 |
-
|
400 |
-
Returns:
|
401 |
-
The caller's phone number or None if not available
|
402 |
-
"""
|
403 |
return self.get_caller_info()["caller_number"]
|
404 |
|
405 |
async def start_dialout(self, transport, dialout_settings=None):
|
406 |
-
"""Helper function to start dialout using the provided settings or from body.
|
407 |
-
|
408 |
-
Args:
|
409 |
-
transport: The transport instance to use for dialout
|
410 |
-
dialout_settings: Optional override for dialout settings
|
411 |
-
|
412 |
-
Returns:
|
413 |
-
None
|
414 |
-
"""
|
415 |
-
# Use provided settings or get from body
|
416 |
settings = dialout_settings or self.get_dialout_settings()
|
417 |
if not settings:
|
418 |
logger.warning("No dialout settings available")
|
419 |
return
|
420 |
-
|
421 |
for setting in settings:
|
422 |
if "phoneNumber" in setting:
|
423 |
logger.info(f"Dialing number: {setting['phoneNumber']}")
|
424 |
if "callerId" in setting:
|
425 |
logger.info(f"with callerId: {setting['callerId']}")
|
426 |
-
await transport.start_dialout(
|
427 |
-
{"phoneNumber": setting["phoneNumber"], "callerId": setting["callerId"]}
|
428 |
-
)
|
429 |
else:
|
430 |
logger.info("with no callerId")
|
431 |
await transport.start_dialout({"phoneNumber": setting["phoneNumber"]})
|
@@ -436,59 +209,27 @@ class CallConfigManager:
|
|
436 |
logger.warning(f"Unknown dialout setting format: {setting}")
|
437 |
|
438 |
def get_dialout_settings(self) -> Optional[List[Dict[str, Any]]]:
|
439 |
-
"""Extract dialout settings from the body.
|
440 |
-
|
441 |
-
Returns:
|
442 |
-
List of dialout setting objects or None if not present
|
443 |
-
"""
|
444 |
-
# Check if we have dialout settings
|
445 |
if "dialout_settings" in self.body:
|
446 |
dialout_settings = self.body["dialout_settings"]
|
447 |
-
|
448 |
-
# Convert to list if it's an object (for backward compatibility)
|
449 |
if isinstance(dialout_settings, dict):
|
450 |
return [dialout_settings]
|
451 |
elif isinstance(dialout_settings, list):
|
452 |
return dialout_settings
|
453 |
-
|
454 |
return None
|
455 |
|
456 |
def get_dialin_settings(self) -> Optional[Dict[str, Any]]:
|
457 |
-
"""Extract dialin settings from the body.
|
458 |
-
|
459 |
-
Handles both camelCase and snake_case variations of fields for backward compatibility,
|
460 |
-
but normalizes to snake_case for internal usage.
|
461 |
-
|
462 |
-
Returns:
|
463 |
-
Dictionary containing dialin settings or None if not present
|
464 |
-
"""
|
465 |
raw_dialin_settings = self.body.get("dialin_settings")
|
466 |
if not raw_dialin_settings:
|
467 |
return None
|
468 |
-
|
469 |
-
# Normalize dialin settings to handle different case variations
|
470 |
-
# Prioritize snake_case (call_id, call_domain) but fall back to camelCase (callId, callDomain)
|
471 |
dialin_settings = {
|
472 |
"call_id": raw_dialin_settings.get("call_id") or raw_dialin_settings.get("callId"),
|
473 |
-
"call_domain": raw_dialin_settings.get("call_domain")
|
474 |
-
or raw_dialin_settings.get("callDomain"),
|
475 |
"to": raw_dialin_settings.get("to") or raw_dialin_settings.get("To"),
|
476 |
"from": raw_dialin_settings.get("from") or raw_dialin_settings.get("From"),
|
477 |
}
|
478 |
-
|
479 |
return dialin_settings
|
480 |
|
481 |
-
# Bot prompt helper functions - no defaults provided, just return what's in the body
|
482 |
-
|
483 |
def get_prompt(self, prompt_name: str) -> Optional[str]:
|
484 |
-
"""Retrieve the prompt text for a given prompt name.
|
485 |
-
|
486 |
-
Args:
|
487 |
-
prompt_name: The name of the prompt to retrieve.
|
488 |
-
|
489 |
-
Returns:
|
490 |
-
The prompt string corresponding to the provided name, or None if not configured.
|
491 |
-
"""
|
492 |
prompts = self.body.get("prompts", [])
|
493 |
for prompt in prompts:
|
494 |
if prompt.get("name") == prompt_name:
|
@@ -496,41 +237,21 @@ class CallConfigManager:
|
|
496 |
return None
|
497 |
|
498 |
def get_transfer_mode(self) -> Optional[str]:
|
499 |
-
"""Get transfer mode from the body.
|
500 |
-
|
501 |
-
Returns:
|
502 |
-
Transfer mode string or None if not configured
|
503 |
-
"""
|
504 |
if "call_transfer" in self.body:
|
505 |
return self.body["call_transfer"].get("mode")
|
506 |
return None
|
507 |
|
508 |
def get_speak_summary(self) -> Optional[bool]:
|
509 |
-
"""Get speak summary from the body.
|
510 |
-
|
511 |
-
Returns:
|
512 |
-
Boolean indicating if summary should be spoken or None if not configured
|
513 |
-
"""
|
514 |
if "call_transfer" in self.body:
|
515 |
return self.body["call_transfer"].get("speakSummary")
|
516 |
return None
|
517 |
|
518 |
def get_store_summary(self) -> Optional[bool]:
|
519 |
-
"""Get store summary from the body.
|
520 |
-
|
521 |
-
Returns:
|
522 |
-
Boolean indicating if summary should be stored or None if not configured
|
523 |
-
"""
|
524 |
if "call_transfer" in self.body:
|
525 |
return self.body["call_transfer"].get("storeSummary")
|
526 |
return None
|
527 |
|
528 |
def is_test_mode(self) -> bool:
|
529 |
-
"""Check if running in test mode.
|
530 |
-
|
531 |
-
Returns:
|
532 |
-
Boolean indicating if test mode is enabled
|
533 |
-
"""
|
534 |
if "voicemail_detection" in self.body:
|
535 |
return bool(self.body["voicemail_detection"].get("testInPrebuilt"))
|
536 |
if "call_transfer" in self.body:
|
@@ -542,67 +263,46 @@ class CallConfigManager:
|
|
542 |
return False
|
543 |
|
544 |
def is_voicemail_detection_enabled(self) -> bool:
|
545 |
-
"""Check if voicemail detection is enabled in the body.
|
546 |
-
|
547 |
-
Returns:
|
548 |
-
Boolean indicating if voicemail detection is enabled
|
549 |
-
"""
|
550 |
return bool(self.body.get("voicemail_detection"))
|
551 |
|
552 |
def customize_prompt(self, prompt: str, customer_name: Optional[str] = None) -> str:
|
553 |
-
"""Insert customer name into prompt template if available.
|
554 |
-
|
555 |
-
Args:
|
556 |
-
prompt: The prompt template containing optional {customer_name} placeholders
|
557 |
-
customer_name: Optional customer name to insert
|
558 |
-
|
559 |
-
Returns:
|
560 |
-
Customized prompt with customer name inserted
|
561 |
-
"""
|
562 |
if customer_name and prompt:
|
563 |
return prompt.replace("{customer_name}", customer_name)
|
564 |
return prompt
|
565 |
|
566 |
def create_system_message(self, content: str) -> Dict[str, str]:
|
567 |
-
"""Create a properly formatted system message.
|
568 |
-
|
569 |
-
Args:
|
570 |
-
content: The message content
|
571 |
-
|
572 |
-
Returns:
|
573 |
-
Dictionary with role and content for the system message
|
574 |
-
"""
|
575 |
return {"role": "system", "content": content}
|
576 |
|
577 |
def create_user_message(self, content: str) -> Dict[str, str]:
|
578 |
-
"""Create a properly formatted user message.
|
579 |
-
|
580 |
-
Args:
|
581 |
-
content: The message content
|
582 |
-
|
583 |
-
Returns:
|
584 |
-
Dictionary with role and content for the user message
|
585 |
-
"""
|
586 |
return {"role": "user", "content": content}
|
587 |
|
588 |
-
def get_customer_info_suffix(
|
589 |
-
self, customer_name: Optional[str] = None, preposition: str = "for"
|
590 |
-
) -> str:
|
591 |
-
"""Create a consistent customer info suffix.
|
592 |
-
|
593 |
-
Args:
|
594 |
-
customer_name: Optional customer name
|
595 |
-
preposition: Preposition to use before the name (e.g., "for", "to", "")
|
596 |
-
|
597 |
-
Returns:
|
598 |
-
String with formatted customer info suffix
|
599 |
-
"""
|
600 |
if not customer_name:
|
601 |
return ""
|
602 |
-
|
603 |
-
# Add a space before the preposition if it's not empty
|
604 |
space_prefix = " " if preposition else ""
|
605 |
-
# For non-empty prepositions, add a space after it
|
606 |
space_suffix = " " if preposition else ""
|
607 |
-
|
608 |
return f"{space_prefix}{preposition}{space_suffix}{customer_name}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
#
|
4 |
# SPDX-License-Identifier: BSD 2-Clause License
|
5 |
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
import json
|
7 |
import os
|
8 |
+
import time
|
9 |
from typing import Any, Dict, List, Optional
|
|
|
10 |
from loguru import logger
|
11 |
|
|
|
12 |
class CallFlowState:
|
|
|
|
|
13 |
def __init__(self):
|
|
|
14 |
self.dialed_operator = False
|
15 |
self.operator_connected = False
|
16 |
self.current_operator_index = 0
|
17 |
self.operator_dialout_settings = []
|
18 |
self.summary_finished = False
|
|
|
|
|
19 |
self.voicemail_detected = False
|
20 |
self.human_detected = False
|
21 |
self.voicemail_message_left = False
|
|
|
|
|
22 |
self.call_terminated = False
|
23 |
self.participant_left_early = False
|
24 |
+
self.silence_prompt_count = 0 # Track silence prompts
|
25 |
|
|
|
26 |
def set_operator_dialed(self):
|
|
|
27 |
self.dialed_operator = True
|
28 |
|
29 |
def set_operator_connected(self):
|
|
|
30 |
self.operator_connected = True
|
|
|
31 |
self.summary_finished = False
|
32 |
|
33 |
def set_operator_disconnected(self):
|
|
|
34 |
self.operator_connected = False
|
35 |
self.summary_finished = False
|
36 |
|
37 |
def set_summary_finished(self):
|
|
|
38 |
self.summary_finished = True
|
39 |
|
40 |
def set_operator_dialout_settings(self, settings):
|
|
|
41 |
self.operator_dialout_settings = settings
|
42 |
self.current_operator_index = 0
|
43 |
|
44 |
def get_current_dialout_setting(self):
|
45 |
+
if not self.operator_dialout_settings or self.current_operator_index >= len(self.operator_dialout_settings):
|
|
|
|
|
|
|
46 |
return None
|
47 |
return self.operator_dialout_settings[self.current_operator_index]
|
48 |
|
49 |
def move_to_next_operator(self):
|
|
|
50 |
self.current_operator_index += 1
|
51 |
return self.get_current_dialout_setting()
|
52 |
|
|
|
53 |
def set_voicemail_detected(self):
|
|
|
54 |
self.voicemail_detected = True
|
55 |
self.human_detected = False
|
56 |
|
57 |
def set_human_detected(self):
|
|
|
58 |
self.human_detected = True
|
59 |
self.voicemail_detected = False
|
60 |
|
61 |
def set_voicemail_message_left(self):
|
|
|
62 |
self.voicemail_message_left = True
|
63 |
|
|
|
64 |
def set_call_terminated(self):
|
|
|
65 |
self.call_terminated = True
|
66 |
|
67 |
def set_participant_left_early(self):
|
|
|
68 |
self.participant_left_early = True
|
69 |
|
70 |
+
def increment_silence_prompts(self):
|
71 |
+
self.silence_prompt_count += 1
|
72 |
|
73 |
+
def reset_silence_prompts(self):
|
74 |
+
self.silence_prompt_count = 0
|
75 |
|
76 |
+
class SessionManager:
|
77 |
def __init__(self):
|
78 |
+
self.session_ids = {"operator": None, "customer": None, "bot": None}
|
79 |
+
self.session_id_refs = {"operator": [None], "customer": [None], "bot": [None]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
self.call_flow_state = CallFlowState()
|
81 |
|
82 |
def set_session_id(self, participant_type, session_id):
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
if participant_type in self.session_ids:
|
84 |
self.session_ids[participant_type] = session_id
|
|
|
|
|
85 |
if participant_type in self.session_id_refs:
|
86 |
self.session_id_refs[participant_type][0] = session_id
|
87 |
|
88 |
def get_session_id(self, participant_type):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
89 |
return self.session_ids.get(participant_type)
|
90 |
|
91 |
def get_session_id_ref(self, participant_type):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
92 |
return self.session_id_refs.get(participant_type)
|
93 |
|
94 |
def is_participant_type(self, session_id, participant_type):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
return self.session_ids.get(participant_type) == session_id
|
96 |
|
97 |
def reset_participant(self, participant_type):
|
|
|
|
|
|
|
|
|
|
|
98 |
if participant_type in self.session_ids:
|
99 |
self.session_ids[participant_type] = None
|
|
|
100 |
if participant_type in self.session_id_refs:
|
101 |
self.session_id_refs[participant_type][0] = None
|
|
|
|
|
102 |
if participant_type == "operator":
|
103 |
self.call_flow_state.set_operator_disconnected()
|
104 |
|
|
|
105 |
class CallConfigManager:
|
|
|
|
|
106 |
def __init__(self, body_data: Dict[str, Any] = None):
|
|
|
|
|
|
|
|
|
|
|
107 |
self.body = body_data or {}
|
108 |
+
self.dial_in_from_number = os.environ.get("HF_DIAL_IN_FROM_NUMBER", "+10000000001")
|
109 |
+
self.dial_out_to_number = os.environ.get("HF_DIAL_OUT_TO_NUMBER", "+10000000002")
|
110 |
+
self.operator_number = os.environ.get("HF_OPERATOR_NUMBER", "+10000000003")
|
|
|
|
|
|
|
|
|
111 |
self._initialize_maps()
|
112 |
self._build_reverse_lookup_maps()
|
113 |
|
114 |
def _initialize_maps(self):
|
|
|
|
|
115 |
self.CUSTOMER_MAP = {
|
116 |
+
"Dominic": {"phoneNumber": self.dial_in_from_number},
|
117 |
+
"Stewart": {"phoneNumber": self.dial_out_to_number},
|
118 |
+
"James": {"phoneNumber": "+10000000000", "callerId": "james-caller-id-uuid", "sipUri": "sip:[email protected]"},
|
119 |
+
"Sarah": {"sipUri": "sip:[email protected]"},
|
120 |
+
"Michael": {"phoneNumber": "+16505557890", "callerId": "michael-caller-id-uuid"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
121 |
}
|
|
|
|
|
122 |
self.CUSTOMER_TO_OPERATOR_MAP = {
|
123 |
+
"Dominic": ["Yunyoung", "Maria"],
|
124 |
"Stewart": "Yunyoung",
|
125 |
"James": "Yunyoung",
|
126 |
"Sarah": "Jennifer",
|
127 |
"Michael": "Paul",
|
|
|
128 |
"Default": "Yunyoung",
|
129 |
}
|
|
|
|
|
130 |
self.OPERATOR_CONTACT_MAP = {
|
131 |
+
"Paul": {"phoneNumber": "+12345678904", "callerId": "paul-caller-id-uuid"},
|
132 |
+
"Yunyoung": {"phoneNumber": self.operator_number},
|
133 |
+
"Maria": {"sipUri": "sip:[email protected]"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
134 |
"Jennifer": {"phoneNumber": "+14155559876", "callerId": "jennifer-caller-id-uuid"},
|
135 |
+
"Default": {"phoneNumber": self.operator_number},
|
|
|
|
|
136 |
}
|
137 |
|
138 |
def _build_reverse_lookup_maps(self):
|
|
|
139 |
self._PHONE_TO_CUSTOMER_MAP = {}
|
140 |
self._SIP_TO_CUSTOMER_MAP = {}
|
|
|
141 |
for customer_name, contact_info in self.CUSTOMER_MAP.items():
|
142 |
if "phoneNumber" in contact_info:
|
143 |
self._PHONE_TO_CUSTOMER_MAP[contact_info["phoneNumber"]] = customer_name
|
|
|
146 |
|
147 |
@classmethod
|
148 |
def from_json_string(cls, json_string: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
149 |
body_data = json.loads(json_string)
|
150 |
return cls(body_data)
|
151 |
|
152 |
def find_customer_by_contact(self, contact_info: str) -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
153 |
if contact_info in self._PHONE_TO_CUSTOMER_MAP:
|
154 |
return self._PHONE_TO_CUSTOMER_MAP[contact_info]
|
|
|
|
|
155 |
if contact_info in self._SIP_TO_CUSTOMER_MAP:
|
156 |
return self._SIP_TO_CUSTOMER_MAP[contact_info]
|
|
|
157 |
return None
|
158 |
|
159 |
def get_customer_name(self, phone_number: str) -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
160 |
return self.find_customer_by_contact(phone_number)
|
161 |
|
162 |
def get_operators_for_customer(self, customer_name: Optional[str]) -> List[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
163 |
if not customer_name or customer_name not in self.CUSTOMER_TO_OPERATOR_MAP:
|
164 |
return ["Default"]
|
|
|
165 |
operators = self.CUSTOMER_TO_OPERATOR_MAP[customer_name]
|
|
|
166 |
if isinstance(operators, str):
|
167 |
return [operators]
|
168 |
return operators
|
169 |
|
170 |
def get_operator_dialout_settings(self, operator_name: str) -> Dict[str, str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
171 |
return self.OPERATOR_CONTACT_MAP.get(operator_name, self.OPERATOR_CONTACT_MAP["Default"])
|
172 |
|
173 |
+
def get_dialout_settings_for_caller(self, from_number: Optional[str] = None) -> List[Dict[str, str]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
174 |
if not from_number:
|
|
|
175 |
return [self.get_operator_dialout_settings("Default")]
|
|
|
|
|
176 |
customer_name = self.get_customer_name(from_number)
|
|
|
|
|
177 |
operator_names = self.get_operators_for_customer(customer_name)
|
|
|
|
|
178 |
return [self.get_operator_dialout_settings(name) for name in operator_names]
|
179 |
|
180 |
def get_caller_info(self) -> Dict[str, Optional[str]]:
|
|
|
|
|
|
|
|
|
|
|
181 |
raw_dialin_settings = self.body.get("dialin_settings")
|
182 |
if not raw_dialin_settings:
|
183 |
return {"caller_number": None, "dialed_number": None}
|
|
|
|
|
184 |
dialed_number = raw_dialin_settings.get("To") or raw_dialin_settings.get("to")
|
185 |
caller_number = raw_dialin_settings.get("From") or raw_dialin_settings.get("from")
|
|
|
186 |
return {"caller_number": caller_number, "dialed_number": dialed_number}
|
187 |
|
188 |
def get_caller_number(self) -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
|
189 |
return self.get_caller_info()["caller_number"]
|
190 |
|
191 |
async def start_dialout(self, transport, dialout_settings=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
192 |
settings = dialout_settings or self.get_dialout_settings()
|
193 |
if not settings:
|
194 |
logger.warning("No dialout settings available")
|
195 |
return
|
|
|
196 |
for setting in settings:
|
197 |
if "phoneNumber" in setting:
|
198 |
logger.info(f"Dialing number: {setting['phoneNumber']}")
|
199 |
if "callerId" in setting:
|
200 |
logger.info(f"with callerId: {setting['callerId']}")
|
201 |
+
await transport.start_dialout({"phoneNumber": setting["phoneNumber"], "callerId": setting["callerId"]})
|
|
|
|
|
202 |
else:
|
203 |
logger.info("with no callerId")
|
204 |
await transport.start_dialout({"phoneNumber": setting["phoneNumber"]})
|
|
|
209 |
logger.warning(f"Unknown dialout setting format: {setting}")
|
210 |
|
211 |
def get_dialout_settings(self) -> Optional[List[Dict[str, Any]]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
212 |
if "dialout_settings" in self.body:
|
213 |
dialout_settings = self.body["dialout_settings"]
|
|
|
|
|
214 |
if isinstance(dialout_settings, dict):
|
215 |
return [dialout_settings]
|
216 |
elif isinstance(dialout_settings, list):
|
217 |
return dialout_settings
|
|
|
218 |
return None
|
219 |
|
220 |
def get_dialin_settings(self) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
221 |
raw_dialin_settings = self.body.get("dialin_settings")
|
222 |
if not raw_dialin_settings:
|
223 |
return None
|
|
|
|
|
|
|
224 |
dialin_settings = {
|
225 |
"call_id": raw_dialin_settings.get("call_id") or raw_dialin_settings.get("callId"),
|
226 |
+
"call_domain": raw_dialin_settings.get("call_domain") or raw_dialin_settings.get("callDomain"),
|
|
|
227 |
"to": raw_dialin_settings.get("to") or raw_dialin_settings.get("To"),
|
228 |
"from": raw_dialin_settings.get("from") or raw_dialin_settings.get("From"),
|
229 |
}
|
|
|
230 |
return dialin_settings
|
231 |
|
|
|
|
|
232 |
def get_prompt(self, prompt_name: str) -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
233 |
prompts = self.body.get("prompts", [])
|
234 |
for prompt in prompts:
|
235 |
if prompt.get("name") == prompt_name:
|
|
|
237 |
return None
|
238 |
|
239 |
def get_transfer_mode(self) -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
|
240 |
if "call_transfer" in self.body:
|
241 |
return self.body["call_transfer"].get("mode")
|
242 |
return None
|
243 |
|
244 |
def get_speak_summary(self) -> Optional[bool]:
|
|
|
|
|
|
|
|
|
|
|
245 |
if "call_transfer" in self.body:
|
246 |
return self.body["call_transfer"].get("speakSummary")
|
247 |
return None
|
248 |
|
249 |
def get_store_summary(self) -> Optional[bool]:
|
|
|
|
|
|
|
|
|
|
|
250 |
if "call_transfer" in self.body:
|
251 |
return self.body["call_transfer"].get("storeSummary")
|
252 |
return None
|
253 |
|
254 |
def is_test_mode(self) -> bool:
|
|
|
|
|
|
|
|
|
|
|
255 |
if "voicemail_detection" in self.body:
|
256 |
return bool(self.body["voicemail_detection"].get("testInPrebuilt"))
|
257 |
if "call_transfer" in self.body:
|
|
|
263 |
return False
|
264 |
|
265 |
def is_voicemail_detection_enabled(self) -> bool:
|
|
|
|
|
|
|
|
|
|
|
266 |
return bool(self.body.get("voicemail_detection"))
|
267 |
|
268 |
def customize_prompt(self, prompt: str, customer_name: Optional[str] = None) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
269 |
if customer_name and prompt:
|
270 |
return prompt.replace("{customer_name}", customer_name)
|
271 |
return prompt
|
272 |
|
273 |
def create_system_message(self, content: str) -> Dict[str, str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
274 |
return {"role": "system", "content": content}
|
275 |
|
276 |
def create_user_message(self, content: str) -> Dict[str, str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
277 |
return {"role": "user", "content": content}
|
278 |
|
279 |
+
def get_customer_info_suffix(self, customer_name: Optional[str] = None, preposition: str = "for") -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
280 |
if not customer_name:
|
281 |
return ""
|
|
|
|
|
282 |
space_prefix = " " if preposition else ""
|
|
|
283 |
space_suffix = " " if preposition else ""
|
|
|
284 |
return f"{space_prefix}{preposition}{space_suffix}{customer_name}"
|
285 |
+
|
286 |
+
async def log_call_summary(self, start_time: float, session_manager: SessionManager, caller_number: str, dialed_number: str, customer_name: str):
|
287 |
+
"""Log call summary to a file."""
|
288 |
+
end_time = time.time()
|
289 |
+
duration = end_time - start_time
|
290 |
+
silence_prompts = session_manager.call_flow_state.silence_prompt_count
|
291 |
+
summary = {
|
292 |
+
"call_start_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(start_time)),
|
293 |
+
"call_end_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(end_time)),
|
294 |
+
"duration_seconds": round(duration, 2),
|
295 |
+
"caller_number": caller_number,
|
296 |
+
"dialed_number": dialed_number,
|
297 |
+
"customer_name": customer_name,
|
298 |
+
"silence_prompts_triggered": silence_prompts,
|
299 |
+
"call_terminated_by_bot": session_manager.call_flow_state.call_terminated,
|
300 |
+
"participant_left_early": session_manager.call_flow_state.participant_left_early
|
301 |
+
}
|
302 |
+
log_file = "/home/user/call_summary.log"
|
303 |
+
try:
|
304 |
+
with open(log_file, "a") as f:
|
305 |
+
f.write(json.dumps(summary) + "\n")
|
306 |
+
logger.info(f"Call summary logged to {log_file}")
|
307 |
+
except Exception as e:
|
308 |
+
logger.error(f"Failed to log call summary: {e}")
|