Update app.py
Browse files
app.py
CHANGED
|
@@ -1,25 +1,18 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
#
|
| 3 |
-
# san_integration_script.py (
|
| 4 |
-
# ======================================================
|
| 5 |
# Description:
|
| 6 |
# - Establishes a real-time, two-way audio bridge between a SAN system
|
| 7 |
# and the Millis AI platform.
|
| 8 |
-
# -
|
| 9 |
-
# -
|
| 10 |
-
# - Receives the AI's audio response at 16kHz.
|
| 11 |
-
# - Streams the audio back to the SAN system using the exact format
|
| 12 |
-
# it originally specified.
|
| 13 |
#
|
| 14 |
# Changes in this version:
|
| 15 |
-
# -
|
| 16 |
-
#
|
| 17 |
-
# -
|
| 18 |
-
#
|
| 19 |
-
# - Updated the final connection check to use `WebSocketState.DISCONNECTED`.
|
| 20 |
-
# - Added comprehensive logging for debugging audio flow.
|
| 21 |
-
# - Fixed agent ID extraction to use platform_agent_id from extraParams.
|
| 22 |
-
# - Added MongoDB integration for credential management.
|
| 23 |
# -------------------------------------------------------------------
|
| 24 |
|
| 25 |
import os
|
|
@@ -35,8 +28,6 @@ from dotenv import load_dotenv
|
|
| 35 |
load_dotenv()
|
| 36 |
|
| 37 |
# Third-party libraries
|
| 38 |
-
import numpy as np
|
| 39 |
-
from scipy import signal as scipy_signal
|
| 40 |
import websockets
|
| 41 |
from websockets.connection import State as WsState
|
| 42 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
@@ -54,7 +45,6 @@ logging.basicConfig(
|
|
| 54 |
logger = logging.getLogger("san-integration-app")
|
| 55 |
|
| 56 |
# ---------- Environment & Configuration -----------------------------------
|
| 57 |
-
#AGENT_ID = os.getenv("MILLIS_AGENT_ID")
|
| 58 |
PUBLIC_KEY = os.getenv("MILLIS_PUBLIC_KEY")
|
| 59 |
MILLIS_WS_URI = "wss://api-west.millis.ai:8080/millis"
|
| 60 |
|
|
@@ -66,40 +56,15 @@ MONGODB_COLLECTION = os.getenv("MONGODB_COLLECTION", "call_metadata")
|
|
| 66 |
# ---------- FastAPI Application -------------------------------------------
|
| 67 |
app = FastAPI()
|
| 68 |
|
| 69 |
-
# def validate_environment():
|
| 70 |
-
# """Validate that all required environment variables are set."""
|
| 71 |
-
# required_vars = {
|
| 72 |
-
# "MILLIS_AGENT_ID": AGENT_ID,
|
| 73 |
-
# "MILLIS_PUBLIC_KEY": PUBLIC_KEY,
|
| 74 |
-
# "MONGODB_CONNECTION_STRING": MONGODB_CONNECTION_STRING,
|
| 75 |
-
# "MONGODB_DATABASE_NAME": MONGODB_DATABASE_NAME,
|
| 76 |
-
# "MONGODB_COLLECTION": MONGODB_COLLECTION
|
| 77 |
-
# }
|
| 78 |
-
|
| 79 |
-
# missing_vars = [var for var, value in required_vars.items() if not value]
|
| 80 |
-
|
| 81 |
-
# if missing_vars:
|
| 82 |
-
# error_msg = f"Missing required environment variables: {', '.join(missing_vars)}"
|
| 83 |
-
# logger.error(error_msg)
|
| 84 |
-
# logger.error("Please set these variables in your .env file or environment")
|
| 85 |
-
# raise ValueError(error_msg)
|
| 86 |
-
|
| 87 |
@app.on_event("startup")
|
| 88 |
async def startup_event():
|
| 89 |
"""Initialize MongoDB connection on startup."""
|
| 90 |
logger.info("=== APPLICATION STARTUP ===")
|
| 91 |
|
| 92 |
-
# Validate environment variables first
|
| 93 |
-
#validate_environment()
|
| 94 |
-
|
| 95 |
success = await connect_mongodb()
|
| 96 |
if not success:
|
| 97 |
logger.error("Failed to connect to MongoDB during startup")
|
| 98 |
logger.warning("Application will continue running but MongoDB features will be disabled")
|
| 99 |
-
logger.info("To fix this issue:")
|
| 100 |
-
logger.info("1. Ensure MongoDB is installed and running")
|
| 101 |
-
logger.info("2. Set MONGODB_CONNECTION_STRING environment variable")
|
| 102 |
-
logger.info("3. For local development: mongodb://localhost:27017")
|
| 103 |
else:
|
| 104 |
logger.info("MongoDB connection established successfully")
|
| 105 |
logger.info("=== STARTUP COMPLETE ===")
|
|
@@ -123,7 +88,6 @@ async def connect_mongodb():
|
|
| 123 |
|
| 124 |
if not MONGODB_CONNECTION_STRING or MONGODB_CONNECTION_STRING == "MONGODB_DATABASE_NAME":
|
| 125 |
logger.error("Invalid MongoDB connection string. Please set MONGODB_CONNECTION_STRING environment variable.")
|
| 126 |
-
logger.error("Example: mongodb://localhost:27017 or mongodb://username:password@host:port/database")
|
| 127 |
return False
|
| 128 |
|
| 129 |
mongodb_client = AsyncIOMotorClient(MONGODB_CONNECTION_STRING)
|
|
@@ -135,8 +99,6 @@ async def connect_mongodb():
|
|
| 135 |
return True
|
| 136 |
except Exception as e:
|
| 137 |
logger.error(f"Failed to connect to MongoDB: {e}")
|
| 138 |
-
logger.error("Please ensure MongoDB is running and the connection string is correct.")
|
| 139 |
-
logger.error("For local development, try: mongodb://localhost:27017")
|
| 140 |
return False
|
| 141 |
|
| 142 |
async def close_mongodb():
|
|
@@ -147,16 +109,7 @@ async def close_mongodb():
|
|
| 147 |
logger.info("MongoDB connection closed")
|
| 148 |
|
| 149 |
def filter_metadata_for_millis(document: dict) -> dict:
|
| 150 |
-
"""
|
| 151 |
-
Filter MongoDB document to exclude unwanted fields before sending to Millis AI.
|
| 152 |
-
|
| 153 |
-
Args:
|
| 154 |
-
document: The MongoDB document containing call data
|
| 155 |
-
|
| 156 |
-
Returns:
|
| 157 |
-
Filtered dictionary with only the fields that should be sent to Millis AI
|
| 158 |
-
"""
|
| 159 |
-
# Fields to exclude from metadata sent to Millis AI
|
| 160 |
excluded_fields = {
|
| 161 |
"public_key", "call_id", "platform_agent_id", "stored_at", "agent_id"
|
| 162 |
}
|
|
@@ -165,38 +118,25 @@ def filter_metadata_for_millis(document: dict) -> dict:
|
|
| 165 |
|
| 166 |
for key, value in document.items():
|
| 167 |
if key not in excluded_fields:
|
| 168 |
-
# Convert value to string if it's not None
|
| 169 |
if value is not None:
|
| 170 |
filtered_metadata[key] = str(value)
|
| 171 |
else:
|
| 172 |
filtered_metadata[key] = ""
|
| 173 |
|
| 174 |
-
logger.info(f"Filtered metadata - Original fields: {list(document.keys())}")
|
| 175 |
-
logger.info(f"Filtered metadata - Excluded fields: {list(excluded_fields)}")
|
| 176 |
logger.info(f"Filtered metadata - Final fields: {list(filtered_metadata.keys())}")
|
| 177 |
-
|
| 178 |
return filtered_metadata
|
| 179 |
|
| 180 |
async def fetch_call_credentials(call_id: str) -> Dict[str, Any]:
|
| 181 |
-
"""
|
| 182 |
-
Fetch call credentials and metadata from MongoDB.
|
| 183 |
-
|
| 184 |
-
Args:
|
| 185 |
-
call_id: The call ID to look up
|
| 186 |
-
|
| 187 |
-
Returns:
|
| 188 |
-
Dictionary containing platform_agent_id, public_key, metadata, and agent_id
|
| 189 |
-
"""
|
| 190 |
if mongodb_db is None:
|
| 191 |
logger.error("MongoDB not connected")
|
| 192 |
return {}
|
| 193 |
|
| 194 |
try:
|
| 195 |
logger.info(f"Fetching credentials for call_id: {call_id}")
|
| 196 |
-
logger.info(f"MongoDB collection: {MONGODB_COLLECTION}")
|
| 197 |
collection = mongodb_db[MONGODB_COLLECTION]
|
| 198 |
|
| 199 |
-
# Query for the call metadata
|
| 200 |
possible_queries = [
|
| 201 |
{"call_id": call_id},
|
| 202 |
{"metadata.call_id": call_id},
|
|
@@ -205,53 +145,25 @@ async def fetch_call_credentials(call_id: str) -> Dict[str, Any]:
|
|
| 205 |
]
|
| 206 |
|
| 207 |
document = None
|
| 208 |
-
for
|
| 209 |
-
logger.info(f"MongoDB query attempt {i+1}: {query}")
|
| 210 |
document = await collection.find_one(query)
|
| 211 |
if document:
|
| 212 |
-
logger.info(f"Found document with query: {query}")
|
| 213 |
break
|
| 214 |
|
| 215 |
-
if not document:
|
| 216 |
-
logger.info(f"No document found for call_id: {call_id}")
|
| 217 |
-
logger.info("Available documents in collection:")
|
| 218 |
-
all_docs = await collection.find({}).to_list(length=10)
|
| 219 |
-
for doc in all_docs:
|
| 220 |
-
if "call_id" in doc:
|
| 221 |
-
logger.info(f" - call_id: {doc['call_id']}")
|
| 222 |
-
if "metadata" in doc and "call_id" in doc["metadata"]:
|
| 223 |
-
logger.info(f" - metadata.call_id: {doc['metadata']['call_id']}")
|
| 224 |
-
|
| 225 |
-
# Use the first available document with metadata as fallback
|
| 226 |
-
for doc in all_docs:
|
| 227 |
-
if "metadata" in doc and doc["metadata"]:
|
| 228 |
-
logger.info(f"Using fallback document with call_id: {doc.get('call_id', 'unknown')}")
|
| 229 |
-
document = doc
|
| 230 |
-
break
|
| 231 |
-
|
| 232 |
if not document:
|
| 233 |
logger.warning(f"No credentials found for call_id: {call_id}")
|
| 234 |
-
logger.info("MongoDB query returned: None")
|
| 235 |
return {}
|
| 236 |
|
| 237 |
-
# Log document with masked sensitive data
|
| 238 |
-
safe_document = document.copy()
|
| 239 |
-
if "public_key" in safe_document:
|
| 240 |
-
safe_document["public_key"] = safe_document["public_key"][:10] + "..." if safe_document["public_key"] else "None"
|
| 241 |
-
logger.info(f"MongoDB document found: {safe_document}")
|
| 242 |
-
|
| 243 |
# Filter the document to exclude unwanted fields for Millis AI
|
| 244 |
filtered_metadata = filter_metadata_for_millis(document)
|
| 245 |
|
| 246 |
-
# Extract the required fields
|
| 247 |
credentials = {
|
| 248 |
"platform_agent_id": document.get("platform_agent_id"),
|
| 249 |
"public_key": document.get("public_key"),
|
| 250 |
"metadata": filtered_metadata
|
| 251 |
}
|
| 252 |
|
| 253 |
-
logger.info(f"Retrieved credentials for call_id {call_id}
|
| 254 |
-
logger.info(f"Metadata keys: {list(credentials.get('metadata', {}).keys())}")
|
| 255 |
return credentials
|
| 256 |
|
| 257 |
except Exception as e:
|
|
@@ -259,17 +171,7 @@ async def fetch_call_credentials(call_id: str) -> Dict[str, Any]:
|
|
| 259 |
return {}
|
| 260 |
|
| 261 |
async def fetch_call_credentials_with_fallback(call_id: str, fallback_msg: dict) -> Dict[str, Any]:
|
| 262 |
-
"""
|
| 263 |
-
Fetch call credentials from MongoDB with fallback to message data.
|
| 264 |
-
|
| 265 |
-
Args:
|
| 266 |
-
call_id: The call ID to look up
|
| 267 |
-
fallback_msg: The original message to extract fallback credentials from
|
| 268 |
-
|
| 269 |
-
Returns:
|
| 270 |
-
Dictionary containing platform_agent_id, public_key, and agent_id
|
| 271 |
-
"""
|
| 272 |
-
# Try MongoDB first
|
| 273 |
logger.info(f"=== MONGODB FALLBACK FOR CALL {call_id} ===")
|
| 274 |
credentials = await fetch_call_credentials(call_id)
|
| 275 |
|
|
@@ -277,18 +179,16 @@ async def fetch_call_credentials_with_fallback(call_id: str, fallback_msg: dict)
|
|
| 277 |
logger.info(f"Using MongoDB credentials for call_id: {call_id}")
|
| 278 |
return credentials
|
| 279 |
|
| 280 |
-
# Fallback to message data
|
| 281 |
logger.warning(f"MongoDB credentials incomplete for call_id {call_id}, using message fallback")
|
| 282 |
|
| 283 |
extra_params = fallback_msg.get("extraParams", {})
|
| 284 |
custom_field = fallback_msg.get("custom_field", {})
|
| 285 |
|
| 286 |
-
# Extract agent_id from fallback sources
|
| 287 |
agent_id = (extra_params.get("platform_agent_id") or
|
| 288 |
custom_field.get("agentId") or
|
| 289 |
fallback_msg.get("agentId"))
|
| 290 |
|
| 291 |
-
# Extract public_key from fallback sources
|
| 292 |
public_key = (extra_params.get("publicKey") or extra_params.get("public_key") or
|
| 293 |
custom_field.get("publicKey") or custom_field.get("public_key") or
|
| 294 |
fallback_msg.get("publicKey") or fallback_msg.get("public_key"))
|
|
@@ -299,24 +199,17 @@ async def fetch_call_credentials_with_fallback(call_id: str, fallback_msg: dict)
|
|
| 299 |
"metadata": {}
|
| 300 |
}
|
| 301 |
|
| 302 |
-
logger.warning(f"Using fallback credentials for call_id {call_id}
|
| 303 |
return fallback_credentials
|
| 304 |
|
| 305 |
# ---------------------------------------------------------------------------#
|
| 306 |
-
#
|
| 307 |
# ---------------------------------------------------------------------------#
|
| 308 |
class RealTimeAudioProcessor:
|
| 309 |
"""
|
| 310 |
-
|
| 311 |
-
|
| 312 |
"""
|
| 313 |
-
PHONE_RATE = 8000
|
| 314 |
-
MILLIS_RATE = 16000
|
| 315 |
-
CHUNK_MS = 40 # Reduced from 20ms to 10ms for faster processing
|
| 316 |
-
BYTES_PER_SAMPLE = 2
|
| 317 |
-
|
| 318 |
-
MILLIS_CHUNK_SIZE = int(MILLIS_RATE * CHUNK_MS / 1000 * BYTES_PER_SAMPLE)
|
| 319 |
-
PHONE_CHUNK_SIZE = int(PHONE_RATE * CHUNK_MS / 1000 * BYTES_PER_SAMPLE)
|
| 320 |
|
| 321 |
def __init__(self, agent_id: str, public_key: str, metadata: dict = None):
|
| 322 |
self.agent_id = agent_id
|
|
@@ -324,53 +217,13 @@ class RealTimeAudioProcessor:
|
|
| 324 |
self.metadata = metadata or {}
|
| 325 |
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
| 326 |
self.connected = False
|
| 327 |
-
|
| 328 |
-
self.inbound = bytearray()
|
| 329 |
-
self.outbound = bytearray()
|
| 330 |
-
self.in_lock = asyncio.Lock()
|
| 331 |
-
self.out_lock = asyncio.Lock()
|
| 332 |
-
|
| 333 |
self.is_paused = False
|
| 334 |
self.stream_id: Optional[str] = None
|
| 335 |
self.call_id: Optional[str] = None
|
| 336 |
-
self.media_format: dict = {
|
| 337 |
-
"encoding": "PCM", "sampleRate": self.PHONE_RATE, "channels": 1
|
| 338 |
-
}
|
| 339 |
self._packet_counter = 0
|
| 340 |
-
|
| 341 |
-
# Add interruption detection state
|
| 342 |
-
self.last_interruption_time = 0
|
| 343 |
-
self.interruption_threshold = 0.1 # 100ms threshold for interruption detection
|
| 344 |
-
|
| 345 |
-
# Audio level detection configuration
|
| 346 |
-
self.speech_threshold = 500 # RMS threshold for detecting speech
|
| 347 |
-
self.silence_threshold = 200 # RMS threshold for detecting silence
|
| 348 |
-
self.silence_duration = 1.0 # Seconds of silence before unpausing
|
| 349 |
-
|
| 350 |
-
# Debug logging for audio levels (set to True to tune thresholds)
|
| 351 |
-
self.debug_audio_levels = False
|
| 352 |
-
|
| 353 |
-
def enable_audio_debug(self, enabled: bool = True):
|
| 354 |
-
"""Enable or disable debug logging for audio levels to help tune thresholds."""
|
| 355 |
-
self.debug_audio_levels = enabled
|
| 356 |
-
logger.info(f"Audio level debug logging {'enabled' if enabled else 'disabled'}")
|
| 357 |
-
|
| 358 |
-
def set_audio_thresholds(self, speech_threshold: int = None, silence_threshold: int = None, silence_duration: float = None):
|
| 359 |
-
"""Set audio detection thresholds for interruption detection."""
|
| 360 |
-
if speech_threshold is not None:
|
| 361 |
-
self.speech_threshold = speech_threshold
|
| 362 |
-
if silence_threshold is not None:
|
| 363 |
-
self.silence_threshold = silence_threshold
|
| 364 |
-
if silence_duration is not None:
|
| 365 |
-
self.silence_duration = silence_duration
|
| 366 |
-
|
| 367 |
-
logger.info(f"Audio thresholds updated - Speech: {self.speech_threshold}, Silence: {self.silence_threshold}, Duration: {self.silence_duration}s")
|
| 368 |
|
| 369 |
async def connect(self) -> bool:
|
| 370 |
logger.info(f"Connecting to Millis AI for call {self.call_id}...")
|
| 371 |
-
logger.info(f"Agent ID: {self.agent_id}")
|
| 372 |
-
logger.info(f"Public Key: {self.public_key[:10]}...")
|
| 373 |
-
logger.info(f"Millis URI: {MILLIS_WS_URI}")
|
| 374 |
|
| 375 |
try:
|
| 376 |
logger.info("Establishing WebSocket connection...")
|
|
@@ -388,27 +241,16 @@ class RealTimeAudioProcessor:
|
|
| 388 |
"include_metadata_in_prompt": True
|
| 389 |
}
|
| 390 |
}
|
| 391 |
-
logger.info(f"Millis connection metadata: {self.metadata}")
|
| 392 |
-
logger.info(f"Metadata keys being sent: {list(self.metadata.keys())}")
|
| 393 |
-
logger.info(f"Sample metadata values:")
|
| 394 |
-
for key, value in list(self.metadata.items())[:5]: # Show first 5 items
|
| 395 |
-
logger.info(f" {key}: {value}")
|
| 396 |
-
logger.info(f"Total metadata fields: {len(self.metadata)}")
|
| 397 |
-
logger.info(f"include_metadata_in_prompt: {initiate_payload['data']['include_metadata_in_prompt']}")
|
| 398 |
-
logger.info(f"Sending initiate payload: {json.dumps(initiate_payload, indent=2)}")
|
| 399 |
|
|
|
|
| 400 |
await self.ws.send(json.dumps(initiate_payload))
|
| 401 |
-
logger.info("Initiate payload sent, waiting for response...")
|
| 402 |
|
| 403 |
logger.info("Waiting for Millis AI response...")
|
| 404 |
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
|
| 405 |
-
logger.info(f"Received response: {msg}")
|
| 406 |
|
| 407 |
try:
|
| 408 |
parsed_msg = json.loads(msg)
|
| 409 |
-
logger.info(f"Parsed response: {json.dumps(parsed_msg, indent=2)}")
|
| 410 |
method = parsed_msg.get("method")
|
| 411 |
-
logger.info(f"Response method: {method}")
|
| 412 |
|
| 413 |
if method != "onready":
|
| 414 |
logger.error(f"Expected 'onready' method, got '{method}'")
|
|
@@ -420,27 +262,8 @@ class RealTimeAudioProcessor:
|
|
| 420 |
|
| 421 |
except json.JSONDecodeError as e:
|
| 422 |
logger.error(f"Failed to parse response as JSON: {e}")
|
| 423 |
-
logger.error(f"Raw response: {msg}")
|
| 424 |
raise
|
| 425 |
|
| 426 |
-
except asyncio.TimeoutError:
|
| 427 |
-
logger.error("Connection timeout - Millis AI did not respond within 10 seconds")
|
| 428 |
-
logger.error(f"Agent ID: {self.agent_id}")
|
| 429 |
-
logger.error(f"Public Key: {self.public_key[:10]}...")
|
| 430 |
-
logger.error("Please check:")
|
| 431 |
-
logger.error("1. Network connectivity to Millis AI")
|
| 432 |
-
logger.error("2. Agent ID and Public Key are valid")
|
| 433 |
-
logger.error("3. Millis AI service is running")
|
| 434 |
-
self.connected = False
|
| 435 |
-
return False
|
| 436 |
-
except websockets.exceptions.InvalidURI:
|
| 437 |
-
logger.error(f"Invalid WebSocket URI: {MILLIS_WS_URI}")
|
| 438 |
-
self.connected = False
|
| 439 |
-
return False
|
| 440 |
-
except websockets.exceptions.ConnectionClosed:
|
| 441 |
-
logger.error("WebSocket connection was closed unexpectedly")
|
| 442 |
-
self.connected = False
|
| 443 |
-
return False
|
| 444 |
except Exception as e:
|
| 445 |
logger.error(f"Millis AI connection failed: {type(e).__name__}: {e}")
|
| 446 |
self.connected = False
|
|
@@ -453,143 +276,36 @@ class RealTimeAudioProcessor:
|
|
| 453 |
self.ws = None
|
| 454 |
logger.info("Disconnected from Millis AI.")
|
| 455 |
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
if not
|
| 459 |
-
|
| 460 |
-
if arr.size == 0: return b""
|
| 461 |
-
new_len = int(arr.size * to_rate / from_rate)
|
| 462 |
-
resampled = scipy_signal.resample(arr, new_len).astype(np.int16)
|
| 463 |
-
return resampled.tobytes()
|
| 464 |
-
|
| 465 |
-
async def _clear_buffers_immediately(self):
|
| 466 |
-
"""Immediately clear audio buffers for faster interruption response."""
|
| 467 |
-
async with self.in_lock:
|
| 468 |
-
self.inbound.clear()
|
| 469 |
-
async with self.out_lock:
|
| 470 |
-
self.outbound.clear()
|
| 471 |
-
logger.info("Audio buffers cleared immediately")
|
| 472 |
-
|
| 473 |
-
async def _detect_interruption(self, audio_data: bytes) -> bool:
|
| 474 |
-
"""
|
| 475 |
-
Detect if user is speaking (interruption) by analyzing audio levels.
|
| 476 |
-
Returns True if interruption is detected.
|
| 477 |
-
"""
|
| 478 |
-
if not audio_data:
|
| 479 |
-
return False
|
| 480 |
|
| 481 |
try:
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
if len(audio_array) == 0:
|
| 485 |
-
return False
|
| 486 |
-
|
| 487 |
-
# Calculate RMS (Root Mean Square) to detect audio level
|
| 488 |
-
rms = np.sqrt(np.mean(audio_array.astype(np.float32) ** 2))
|
| 489 |
-
|
| 490 |
-
# Debug logging for threshold tuning
|
| 491 |
-
if self.debug_audio_levels and rms > 100: # Only log when there's significant audio
|
| 492 |
-
logger.debug(f"Audio RMS: {rms:.2f}, Speech threshold: {self.speech_threshold}")
|
| 493 |
-
|
| 494 |
-
if rms > self.speech_threshold:
|
| 495 |
-
current_time = asyncio.get_event_loop().time()
|
| 496 |
-
if current_time - self.last_interruption_time > self.interruption_threshold:
|
| 497 |
-
self.last_interruption_time = current_time
|
| 498 |
-
logger.info(f"Interruption detected! RMS: {rms:.2f}, Threshold: {self.speech_threshold}")
|
| 499 |
-
return True
|
| 500 |
-
|
| 501 |
-
except Exception as e:
|
| 502 |
-
logger.warning(f"Error in interruption detection: {e}")
|
| 503 |
-
|
| 504 |
-
return False
|
| 505 |
-
|
| 506 |
-
async def _detect_silence(self, audio_data: bytes) -> bool:
|
| 507 |
-
"""
|
| 508 |
-
Detect if user has stopped speaking (silence) by analyzing audio levels.
|
| 509 |
-
Returns True if silence is detected.
|
| 510 |
-
"""
|
| 511 |
-
if not audio_data:
|
| 512 |
-
return False
|
| 513 |
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
return False
|
| 519 |
|
| 520 |
-
# Calculate RMS (Root Mean Square) to detect audio level
|
| 521 |
-
rms = np.sqrt(np.mean(audio_array.astype(np.float32) ** 2))
|
| 522 |
-
|
| 523 |
-
# Debug logging for threshold tuning
|
| 524 |
-
if self.debug_audio_levels and rms < 300: # Only log when audio is low
|
| 525 |
-
logger.debug(f"Silence check - RMS: {rms:.2f}, Silence threshold: {self.silence_threshold}")
|
| 526 |
-
|
| 527 |
-
if rms < self.silence_threshold:
|
| 528 |
-
current_time = asyncio.get_event_loop().time()
|
| 529 |
-
# Only unpause after configured silence duration to avoid rapid toggling
|
| 530 |
-
if current_time - self.last_interruption_time > self.silence_duration:
|
| 531 |
-
logger.info(f"Silence detected! RMS: {rms:.2f}, Threshold: {self.silence_threshold}")
|
| 532 |
-
return True
|
| 533 |
-
|
| 534 |
except Exception as e:
|
| 535 |
-
logger.
|
| 536 |
-
|
| 537 |
-
return False
|
| 538 |
-
|
| 539 |
-
async def _pump_inbound_to_millis(self):
|
| 540 |
-
logger.info(f"Starting inbound audio pump for call {self.call_id}")
|
| 541 |
-
processed_chunks = 0
|
| 542 |
-
while self.connected:
|
| 543 |
-
chunk8 = None
|
| 544 |
-
async with self.in_lock:
|
| 545 |
-
# Process smaller chunks for faster response
|
| 546 |
-
min_chunk_size = max(self.PHONE_CHUNK_SIZE // 2, 160) # At least 10ms of audio
|
| 547 |
-
if len(self.inbound) >= min_chunk_size:
|
| 548 |
-
chunk8 = self.inbound[:min_chunk_size]
|
| 549 |
-
del self.inbound[:min_chunk_size]
|
| 550 |
-
if not chunk8:
|
| 551 |
-
await asyncio.sleep(0.002) # Reduced sleep time
|
| 552 |
-
continue
|
| 553 |
-
try:
|
| 554 |
-
# Check for interruption before sending to Millis
|
| 555 |
-
if await self._detect_interruption(chunk8):
|
| 556 |
-
# Immediately pause AI and clear buffers
|
| 557 |
-
self.is_paused = True
|
| 558 |
-
await self._clear_buffers_immediately()
|
| 559 |
-
logger.info("AI paused due to user interruption")
|
| 560 |
-
elif self.is_paused and await self._detect_silence(chunk8):
|
| 561 |
-
# Unpause AI when user stops speaking
|
| 562 |
-
self.is_paused = False
|
| 563 |
-
logger.info("AI unpaused due to user silence")
|
| 564 |
-
|
| 565 |
-
chunk16 = self._resample(chunk8, self.PHONE_RATE, self.MILLIS_RATE)
|
| 566 |
-
processed_chunks += 1
|
| 567 |
-
# Only log every 200 chunks to reduce noise
|
| 568 |
-
if processed_chunks % 200 == 0:
|
| 569 |
-
logger.info(f"Processed {processed_chunks} audio chunks to Millis AI")
|
| 570 |
-
await self.ws.send(chunk16)
|
| 571 |
-
self._packet_counter += 1
|
| 572 |
-
if self._packet_counter >= 1_000:
|
| 573 |
-
logger.info("Sending ping to Millis AI")
|
| 574 |
-
await self.ws.send(json.dumps({"method": "ping"}))
|
| 575 |
-
self._packet_counter = 0
|
| 576 |
-
except Exception as e:
|
| 577 |
-
logger.error(f"Error in _pump_inbound_to_millis: {e}")
|
| 578 |
-
self.connected = False
|
| 579 |
|
| 580 |
-
async def
|
| 581 |
-
|
| 582 |
-
|
|
|
|
| 583 |
while self.connected and self.ws and self.ws.state == WsState.OPEN:
|
| 584 |
try:
|
| 585 |
msg = await self.ws.recv()
|
|
|
|
| 586 |
if isinstance(msg, bytes):
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
logger.info(f"Received {received_chunks} audio chunks from Millis AI")
|
| 591 |
-
async with self.out_lock:
|
| 592 |
-
self.outbound.extend(msg)
|
| 593 |
continue
|
| 594 |
|
| 595 |
# Handle JSON messages
|
|
@@ -597,85 +313,58 @@ class RealTimeAudioProcessor:
|
|
| 597 |
evt = json.loads(msg)
|
| 598 |
except json.JSONDecodeError as e:
|
| 599 |
logger.warning(f"Failed to parse JSON message from Millis AI: {e}")
|
| 600 |
-
logger.warning(f"Raw message: {msg}")
|
| 601 |
continue
|
| 602 |
|
| 603 |
method = evt.get("method")
|
| 604 |
-
# Only log important JSON events, not every audio chunk
|
| 605 |
if method not in ["ping", "pong"]:
|
| 606 |
-
logger.info(f"Millis event: {method}
|
|
|
|
| 607 |
if method == "pause":
|
| 608 |
self.is_paused = True
|
| 609 |
logger.info("Audio paused")
|
| 610 |
-
# Immediately clear buffers when paused
|
| 611 |
-
await self._clear_buffers_immediately()
|
| 612 |
elif method == "unpause":
|
| 613 |
self.is_paused = False
|
| 614 |
logger.info("Audio unpaused")
|
| 615 |
elif method in ("clear", "start_answering"):
|
| 616 |
-
await self._clear_buffers_immediately()
|
| 617 |
self.is_paused = False
|
| 618 |
-
logger.info("Audio
|
|
|
|
| 619 |
except websockets.exceptions.ConnectionClosed:
|
| 620 |
logger.warning("Millis AI closed the connection.")
|
| 621 |
self.connected = False
|
| 622 |
except Exception as e:
|
| 623 |
-
logger.
|
| 624 |
-
logger.warning(f"Error details: {str(e)}")
|
| 625 |
self.connected = False
|
| 626 |
|
| 627 |
-
async def
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
await asyncio.sleep(0.002) # Reduced sleep time
|
| 643 |
-
continue
|
| 644 |
-
try:
|
| 645 |
-
target_rate = self.media_format.get("sampleRate", self.PHONE_RATE)
|
| 646 |
-
chunk_resampled = self._resample(chunk16, self.MILLIS_RATE, target_rate)
|
| 647 |
-
payload = base64.b64encode(chunk_resampled).decode()
|
| 648 |
-
sent_packets += 1
|
| 649 |
-
# Only log every 400 packets to reduce noise
|
| 650 |
-
if sent_packets % 400 == 0:
|
| 651 |
-
logger.info(f"Sent {sent_packets} audio packets to SAN")
|
| 652 |
-
|
| 653 |
-
### --- FIX: Modified the JSON payload to match the provider's simple format --- ###
|
| 654 |
-
reverse_media_payload = {
|
| 655 |
-
"event": "reverse-media",
|
| 656 |
-
"callid": self.call_id, # Changed from "callId" to "callid"
|
| 657 |
-
"payload": payload,
|
| 658 |
-
# Removed "streamId" and "mediaFormat" fields
|
| 659 |
-
}
|
| 660 |
-
await client_ws.send_json(reverse_media_payload)
|
| 661 |
-
### --- END FIX --- ###
|
| 662 |
-
|
| 663 |
-
except Exception as e:
|
| 664 |
-
logger.error(f"Error in _pump_outbound_to_carrier: {e}")
|
| 665 |
-
break
|
| 666 |
|
| 667 |
async def start(self, client_ws: WebSocket) -> list[asyncio.Task]:
|
|
|
|
| 668 |
logger.info(f"Starting RealTimeAudioProcessor for call {self.call_id}")
|
|
|
|
| 669 |
if not await self.connect():
|
| 670 |
logger.error("Failed to connect to Millis AI")
|
| 671 |
return []
|
| 672 |
|
| 673 |
-
logger.info("Creating audio processing
|
| 674 |
tasks = [
|
| 675 |
-
asyncio.create_task(self.
|
| 676 |
-
asyncio.create_task(self._pump_inbound_to_millis()),
|
| 677 |
-
asyncio.create_task(self._pump_outbound_to_carrier(client_ws)),
|
| 678 |
]
|
|
|
|
| 679 |
logger.info(f"Created {len(tasks)} tasks")
|
| 680 |
return tasks
|
| 681 |
|
|
@@ -701,160 +390,72 @@ async def media_socket(ws: WebSocket):
|
|
| 701 |
try:
|
| 702 |
while True:
|
| 703 |
raw = await ws.receive_text()
|
| 704 |
-
|
| 705 |
msg = json.loads(raw)
|
| 706 |
event = msg.get("event")
|
| 707 |
|
| 708 |
-
# Enhanced logging for start events only
|
| 709 |
if event == "start":
|
| 710 |
logger.info("=== START EVENT ===")
|
| 711 |
-
logger.info(f"Call ID: {msg.get('callId')}, Stream ID: {msg.get('streamId')}")
|
| 712 |
-
logger.info(f"Agent ID: {msg.get('extraParams', {}).get('platform_agent_id')}")
|
| 713 |
-
logger.info("=== END START EVENT ===")
|
| 714 |
-
elif event == "media":
|
| 715 |
-
payload_b64 = msg.get("payload")
|
| 716 |
-
if payload_b64:
|
| 717 |
-
pcm = base64.b64decode(payload_b64)
|
| 718 |
-
# Reduce logging frequency for media chunks
|
| 719 |
-
logger.debug(f"Received media chunk: {len(pcm)} bytes for call")
|
| 720 |
-
else:
|
| 721 |
-
logger.warning("Media event received but no payload found")
|
| 722 |
-
else:
|
| 723 |
-
logger.info(f"Received event: {event}")
|
| 724 |
-
|
| 725 |
-
if event == "start":
|
| 726 |
-
logger.info("=== START EVENT PROCESSING ===")
|
| 727 |
new_call_id = msg.get("callId")
|
| 728 |
stream_id = msg.get("streamId")
|
| 729 |
-
logger.info(f"
|
| 730 |
|
| 731 |
if processor and new_call_id != active_call_id:
|
| 732 |
-
logger.info(f"New call detected
|
| 733 |
await stop_processor(processor, tasks)
|
| 734 |
processor, tasks = None, []
|
| 735 |
|
| 736 |
if processor is None:
|
| 737 |
logger.info(f"Starting processor for call: {new_call_id}")
|
| 738 |
|
| 739 |
-
# Extract credentials
|
| 740 |
-
logger.info(f"=== EXTRACTING CREDENTIALS FOR CALL {new_call_id} ===")
|
| 741 |
extra_params = msg.get("extraParams", {})
|
| 742 |
-
|
| 743 |
-
# Get credentials from message
|
| 744 |
agent_id = extra_params.get("platform_agent_id")
|
| 745 |
-
|
| 746 |
-
# Always use environment variable for public_key (primary source)
|
| 747 |
public_key = PUBLIC_KEY
|
| 748 |
-
logger.info(f"Using environment PUBLIC_KEY: {public_key[:10] + '...' if public_key else 'None'}")
|
| 749 |
|
| 750 |
-
|
| 751 |
-
if not agent_id:
|
| 752 |
-
agent_id = AGENT_ID
|
| 753 |
-
logger.info(f"Using environment AGENT_ID: {agent_id}")
|
| 754 |
-
|
| 755 |
-
# If agent_id still missing, try MongoDB as final fallback
|
| 756 |
-
if not agent_id:
|
| 757 |
-
logger.warning("Agent ID missing, trying MongoDB fallback")
|
| 758 |
credentials = await fetch_call_credentials_with_fallback(new_call_id, msg)
|
| 759 |
-
agent_id = credentials.get("platform_agent_id")
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
logger.info(f"Using credentials - agent_id: {agent_id}, public_key: {public_key[:10] + '...' if public_key else 'None'}")
|
| 763 |
|
| 764 |
if not agent_id:
|
| 765 |
logger.error(f"No agent_id found for call_id: {new_call_id}")
|
| 766 |
-
logger.error("Closing WebSocket due to missing agent_id")
|
| 767 |
await ws.close(code=1008, reason="Missing agent_id")
|
| 768 |
return
|
| 769 |
|
| 770 |
if not public_key:
|
| 771 |
-
logger.error("No public_key found
|
| 772 |
-
logger.error("Closing WebSocket due to missing public_key")
|
| 773 |
await ws.close(code=1008, reason="Missing public_key")
|
| 774 |
return
|
| 775 |
|
| 776 |
-
#
|
| 777 |
-
logger.info("=== FETCHING METADATA ===")
|
| 778 |
-
extra_params = msg.get("extraParams", {})
|
| 779 |
mongodb_call_id = extra_params.get("call_id")
|
| 780 |
-
|
| 781 |
if mongodb_call_id:
|
| 782 |
-
logger.info(f"Found call_id in extraParams: {mongodb_call_id}")
|
| 783 |
mongodb_credentials = await fetch_call_credentials(mongodb_call_id)
|
| 784 |
metadata = mongodb_credentials.get("metadata", {})
|
| 785 |
-
|
| 786 |
-
if metadata:
|
| 787 |
-
logger.info(f"Using MongoDB metadata with {len(metadata)} fields")
|
| 788 |
-
logger.info(f"MongoDB metadata keys: {list(metadata.keys())}")
|
| 789 |
-
# Merge with extraParams to include platform_agent_id
|
| 790 |
-
metadata.update(extra_params)
|
| 791 |
-
logger.info(f"Metadata after merging with extraParams: {dict(list(metadata.items())[:3])}")
|
| 792 |
-
else:
|
| 793 |
-
logger.info("No MongoDB metadata found, using extraParams")
|
| 794 |
-
metadata = extra_params
|
| 795 |
else:
|
| 796 |
-
|
| 797 |
-
mongodb_credentials = await fetch_call_credentials(new_call_id)
|
| 798 |
-
metadata = mongodb_credentials.get("metadata", {})
|
| 799 |
-
|
| 800 |
-
if metadata:
|
| 801 |
-
logger.info(f"Using MongoDB metadata with {len(metadata)} fields")
|
| 802 |
-
logger.info(f"MongoDB metadata keys: {list(metadata.keys())}")
|
| 803 |
-
metadata.update(extra_params)
|
| 804 |
-
else:
|
| 805 |
-
logger.info("No MongoDB metadata found, using extraParams")
|
| 806 |
-
metadata = extra_params
|
| 807 |
-
|
| 808 |
-
logger.info("=== METADATA RESOLVED ===")
|
| 809 |
|
| 810 |
-
logger.info("
|
| 811 |
-
logger.info(f" - agent_id: {agent_id}")
|
| 812 |
-
logger.info(f" - public_key: {public_key[:10]}...")
|
| 813 |
-
logger.info(f" - metadata: {metadata}")
|
| 814 |
-
|
| 815 |
-
logger.info(f"=== CREATING PROCESSOR FOR CALL {new_call_id} ===")
|
| 816 |
processor = RealTimeAudioProcessor(agent_id, public_key, metadata)
|
| 817 |
processor.stream_id = stream_id
|
| 818 |
processor.call_id = new_call_id
|
| 819 |
-
|
| 820 |
-
# Set audio thresholds from environment or use defaults
|
| 821 |
-
speech_threshold = int(os.getenv("SPEECH_THRESHOLD", "500"))
|
| 822 |
-
silence_threshold = int(os.getenv("SILENCE_THRESHOLD", "200"))
|
| 823 |
-
silence_duration = float(os.getenv("SILENCE_DURATION", "1.0"))
|
| 824 |
-
enable_debug = os.getenv("AUDIO_DEBUG", "false").lower() == "true"
|
| 825 |
-
|
| 826 |
-
processor.set_audio_thresholds(speech_threshold, silence_threshold, silence_duration)
|
| 827 |
-
if enable_debug:
|
| 828 |
-
processor.enable_audio_debug(True)
|
| 829 |
-
|
| 830 |
-
logger.info(f"Audio processor configured - Speech: {speech_threshold}, Silence: {silence_threshold}, Duration: {silence_duration}, Debug: {enable_debug}")
|
| 831 |
-
|
| 832 |
-
if "mediaFormat" in msg:
|
| 833 |
-
processor.media_format = msg["mediaFormat"]
|
| 834 |
-
logger.info(f"Captured media format from SAN: {processor.media_format}")
|
| 835 |
-
else:
|
| 836 |
-
logger.warning("No mediaFormat in 'start' event. Using default.")
|
| 837 |
|
| 838 |
-
logger.info(f"=== STARTING PROCESSOR ===")
|
| 839 |
tasks = await processor.start(ws)
|
| 840 |
if not tasks:
|
| 841 |
logger.error("Failed to start processor")
|
| 842 |
await ws.close(code=1011, reason="Could not connect to AI backend.")
|
| 843 |
return
|
| 844 |
active_call_id = new_call_id
|
| 845 |
-
logger.info(
|
| 846 |
continue
|
| 847 |
|
| 848 |
elif event == "media" and processor:
|
| 849 |
payload_b64 = msg.get("payload")
|
| 850 |
if payload_b64:
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
# Reduce logging frequency for media processing
|
| 855 |
-
logger.debug(f"Added {len(pcm)} bytes to inbound buffer for call {active_call_id}")
|
| 856 |
-
else:
|
| 857 |
-
logger.warning("Media event received but no payload found")
|
| 858 |
continue
|
| 859 |
|
| 860 |
elif event in ("hangup", "stop", "disconnect"):
|
|
@@ -872,9 +473,7 @@ async def media_socket(ws: WebSocket):
|
|
| 872 |
|
| 873 |
except WebSocketDisconnect:
|
| 874 |
logger.info("=== WEBSOCKET DISCONNECT ===")
|
| 875 |
-
logger.info("SAN system disconnected the WebSocket.")
|
| 876 |
except Exception as e:
|
| 877 |
-
logger.error("=== UNHANDLED ERROR ===")
|
| 878 |
logger.error(f"Unhandled error in media_socket: {e}", exc_info=True)
|
| 879 |
finally:
|
| 880 |
logger.info("=== FINAL CLEANUP ===")
|
|
@@ -890,7 +489,6 @@ async def media_socket(ws: WebSocket):
|
|
| 890 |
async def health():
|
| 891 |
logger.info("Health check endpoint called")
|
| 892 |
|
| 893 |
-
# Check MongoDB connection status
|
| 894 |
mongodb_status = "connected" if mongodb_client is not None and mongodb_db is not None else "disconnected"
|
| 895 |
|
| 896 |
return {
|
|
@@ -902,7 +500,6 @@ async def health():
|
|
| 902 |
"collection": MONGODB_COLLECTION or "not_set"
|
| 903 |
},
|
| 904 |
"config": {
|
| 905 |
-
"agent_id": "not_set",
|
| 906 |
"public_key": PUBLIC_KEY[:10] + "..." if PUBLIC_KEY else "not_set",
|
| 907 |
"millis_ws_uri": MILLIS_WS_URI
|
| 908 |
}
|
|
@@ -915,7 +512,7 @@ async def test():
|
|
| 915 |
|
| 916 |
@app.get("/test-mongodb")
|
| 917 |
async def test_mongodb():
|
| 918 |
-
"""Test MongoDB connectivity
|
| 919 |
logger.info("MongoDB test endpoint called")
|
| 920 |
|
| 921 |
if mongodb_db is None:
|
|
@@ -924,7 +521,6 @@ async def test_mongodb():
|
|
| 924 |
try:
|
| 925 |
collection = mongodb_db[MONGODB_COLLECTION]
|
| 926 |
|
| 927 |
-
# Test insert
|
| 928 |
test_doc = {
|
| 929 |
"call_id": "test-call-123",
|
| 930 |
"platform_agent_id": "test-agent-456",
|
|
@@ -933,9 +529,6 @@ async def test_mongodb():
|
|
| 933 |
}
|
| 934 |
|
| 935 |
result = await collection.insert_one(test_doc)
|
| 936 |
-
logger.info(f"Test document inserted with ID: {result.inserted_id}")
|
| 937 |
-
|
| 938 |
-
# Test query
|
| 939 |
retrieved = await collection.find_one({"call_id": "test-call-123"})
|
| 940 |
|
| 941 |
return {
|
|
@@ -954,56 +547,10 @@ async def test_mongodb():
|
|
| 954 |
"mongodb_connected": mongodb_db is not None
|
| 955 |
}
|
| 956 |
|
| 957 |
-
@app.get("/audio-config")
|
| 958 |
-
async def get_audio_config():
|
| 959 |
-
"""Get current audio detection configuration."""
|
| 960 |
-
return {
|
| 961 |
-
"chunk_ms": RealTimeAudioProcessor.CHUNK_MS,
|
| 962 |
-
"phone_chunk_size": RealTimeAudioProcessor.PHONE_CHUNK_SIZE,
|
| 963 |
-
"millis_chunk_size": RealTimeAudioProcessor.MILLIS_CHUNK_SIZE,
|
| 964 |
-
"interruption_threshold": 0.1,
|
| 965 |
-
"speech_threshold": 500,
|
| 966 |
-
"silence_threshold": 200,
|
| 967 |
-
"silence_duration": 1.0,
|
| 968 |
-
"description": "Audio detection configuration for interruption handling"
|
| 969 |
-
}
|
| 970 |
-
|
| 971 |
-
@app.post("/audio-config")
|
| 972 |
-
async def update_audio_config(
|
| 973 |
-
speech_threshold: int = None,
|
| 974 |
-
silence_threshold: int = None,
|
| 975 |
-
silence_duration: float = None,
|
| 976 |
-
enable_debug: bool = False
|
| 977 |
-
):
|
| 978 |
-
"""Update audio detection configuration."""
|
| 979 |
-
# Update global defaults for new processors
|
| 980 |
-
if speech_threshold is not None:
|
| 981 |
-
RealTimeAudioProcessor.speech_threshold = speech_threshold
|
| 982 |
-
if silence_threshold is not None:
|
| 983 |
-
RealTimeAudioProcessor.silence_threshold = silence_threshold
|
| 984 |
-
if silence_duration is not None:
|
| 985 |
-
RealTimeAudioProcessor.silence_duration = silence_duration
|
| 986 |
-
|
| 987 |
-
logger.info(f"Audio config updated - Speech: {speech_threshold}, Silence: {silence_threshold}, Duration: {silence_duration}, Debug: {enable_debug}")
|
| 988 |
-
|
| 989 |
-
return {
|
| 990 |
-
"status": "updated",
|
| 991 |
-
"speech_threshold": speech_threshold,
|
| 992 |
-
"silence_threshold": silence_threshold,
|
| 993 |
-
"silence_duration": silence_duration,
|
| 994 |
-
"enable_debug": enable_debug,
|
| 995 |
-
"message": "Audio configuration updated. Changes will apply to new connections."
|
| 996 |
-
}
|
| 997 |
-
|
| 998 |
-
|
| 999 |
if __name__ == "__main__":
|
| 1000 |
-
print("Starting SAN to Millis AI Integration Server (
|
| 1001 |
logger.info("=== SERVER STARTING ===")
|
| 1002 |
logger.info(f"Public Key: {PUBLIC_KEY[:10] + '...' if PUBLIC_KEY else 'NOT_SET'}")
|
| 1003 |
logger.info(f"Millis URI: {MILLIS_WS_URI}")
|
| 1004 |
-
logger.info(f"MongoDB URI: {MONGODB_CONNECTION_STRING or 'NOT_SET'}")
|
| 1005 |
-
logger.info(f"MongoDB Database: {MONGODB_DATABASE_NAME or 'NOT_SET'}")
|
| 1006 |
-
logger.info(f"MongoDB Collection: {MONGODB_COLLECTION or 'NOT_SET'}")
|
| 1007 |
logger.info("=== SERVER READY ===")
|
| 1008 |
uvicorn.run(app, host="0.0.0.0", port=8000)
|
| 1009 |
-
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
#
|
| 3 |
+
# san_integration_script.py (v6 - Simplified Audio Processing)
|
| 4 |
+
# ============================================================
|
| 5 |
# Description:
|
| 6 |
# - Establishes a real-time, two-way audio bridge between a SAN system
|
| 7 |
# and the Millis AI platform.
|
| 8 |
+
# - Direct audio forwarding at 16kHz PCM format (no conversion needed).
|
| 9 |
+
# - Removed buffer logic for direct streaming.
|
|
|
|
|
|
|
|
|
|
| 10 |
#
|
| 11 |
# Changes in this version:
|
| 12 |
+
# - Removed audio format conversion logic (resampling)
|
| 13 |
+
# - Removed buffer management and chunking
|
| 14 |
+
# - Simplified audio flow for direct 16kHz PCM streaming
|
| 15 |
+
# - Removed scipy dependency
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
# -------------------------------------------------------------------
|
| 17 |
|
| 18 |
import os
|
|
|
|
| 28 |
load_dotenv()
|
| 29 |
|
| 30 |
# Third-party libraries
|
|
|
|
|
|
|
| 31 |
import websockets
|
| 32 |
from websockets.connection import State as WsState
|
| 33 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
|
|
| 45 |
logger = logging.getLogger("san-integration-app")
|
| 46 |
|
| 47 |
# ---------- Environment & Configuration -----------------------------------
|
|
|
|
| 48 |
PUBLIC_KEY = os.getenv("MILLIS_PUBLIC_KEY")
|
| 49 |
MILLIS_WS_URI = "wss://api-west.millis.ai:8080/millis"
|
| 50 |
|
|
|
|
| 56 |
# ---------- FastAPI Application -------------------------------------------
|
| 57 |
app = FastAPI()
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
@app.on_event("startup")
|
| 60 |
async def startup_event():
|
| 61 |
"""Initialize MongoDB connection on startup."""
|
| 62 |
logger.info("=== APPLICATION STARTUP ===")
|
| 63 |
|
|
|
|
|
|
|
|
|
|
| 64 |
success = await connect_mongodb()
|
| 65 |
if not success:
|
| 66 |
logger.error("Failed to connect to MongoDB during startup")
|
| 67 |
logger.warning("Application will continue running but MongoDB features will be disabled")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
else:
|
| 69 |
logger.info("MongoDB connection established successfully")
|
| 70 |
logger.info("=== STARTUP COMPLETE ===")
|
|
|
|
| 88 |
|
| 89 |
if not MONGODB_CONNECTION_STRING or MONGODB_CONNECTION_STRING == "MONGODB_DATABASE_NAME":
|
| 90 |
logger.error("Invalid MongoDB connection string. Please set MONGODB_CONNECTION_STRING environment variable.")
|
|
|
|
| 91 |
return False
|
| 92 |
|
| 93 |
mongodb_client = AsyncIOMotorClient(MONGODB_CONNECTION_STRING)
|
|
|
|
| 99 |
return True
|
| 100 |
except Exception as e:
|
| 101 |
logger.error(f"Failed to connect to MongoDB: {e}")
|
|
|
|
|
|
|
| 102 |
return False
|
| 103 |
|
| 104 |
async def close_mongodb():
|
|
|
|
| 109 |
logger.info("MongoDB connection closed")
|
| 110 |
|
| 111 |
def filter_metadata_for_millis(document: dict) -> dict:
|
| 112 |
+
"""Filter MongoDB document to exclude unwanted fields before sending to Millis AI."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
excluded_fields = {
|
| 114 |
"public_key", "call_id", "platform_agent_id", "stored_at", "agent_id"
|
| 115 |
}
|
|
|
|
| 118 |
|
| 119 |
for key, value in document.items():
|
| 120 |
if key not in excluded_fields:
|
|
|
|
| 121 |
if value is not None:
|
| 122 |
filtered_metadata[key] = str(value)
|
| 123 |
else:
|
| 124 |
filtered_metadata[key] = ""
|
| 125 |
|
|
|
|
|
|
|
| 126 |
logger.info(f"Filtered metadata - Final fields: {list(filtered_metadata.keys())}")
|
|
|
|
| 127 |
return filtered_metadata
|
| 128 |
|
| 129 |
async def fetch_call_credentials(call_id: str) -> Dict[str, Any]:
|
| 130 |
+
"""Fetch call credentials and metadata from MongoDB."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
if mongodb_db is None:
|
| 132 |
logger.error("MongoDB not connected")
|
| 133 |
return {}
|
| 134 |
|
| 135 |
try:
|
| 136 |
logger.info(f"Fetching credentials for call_id: {call_id}")
|
|
|
|
| 137 |
collection = mongodb_db[MONGODB_COLLECTION]
|
| 138 |
|
| 139 |
+
# Query for the call metadata
|
| 140 |
possible_queries = [
|
| 141 |
{"call_id": call_id},
|
| 142 |
{"metadata.call_id": call_id},
|
|
|
|
| 145 |
]
|
| 146 |
|
| 147 |
document = None
|
| 148 |
+
for query in possible_queries:
|
|
|
|
| 149 |
document = await collection.find_one(query)
|
| 150 |
if document:
|
|
|
|
| 151 |
break
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
if not document:
|
| 154 |
logger.warning(f"No credentials found for call_id: {call_id}")
|
|
|
|
| 155 |
return {}
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
# Filter the document to exclude unwanted fields for Millis AI
|
| 158 |
filtered_metadata = filter_metadata_for_millis(document)
|
| 159 |
|
|
|
|
| 160 |
credentials = {
|
| 161 |
"platform_agent_id": document.get("platform_agent_id"),
|
| 162 |
"public_key": document.get("public_key"),
|
| 163 |
"metadata": filtered_metadata
|
| 164 |
}
|
| 165 |
|
| 166 |
+
logger.info(f"Retrieved credentials for call_id {call_id}")
|
|
|
|
| 167 |
return credentials
|
| 168 |
|
| 169 |
except Exception as e:
|
|
|
|
| 171 |
return {}
|
| 172 |
|
| 173 |
async def fetch_call_credentials_with_fallback(call_id: str, fallback_msg: dict) -> Dict[str, Any]:
|
| 174 |
+
"""Fetch call credentials from MongoDB with fallback to message data."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
logger.info(f"=== MONGODB FALLBACK FOR CALL {call_id} ===")
|
| 176 |
credentials = await fetch_call_credentials(call_id)
|
| 177 |
|
|
|
|
| 179 |
logger.info(f"Using MongoDB credentials for call_id: {call_id}")
|
| 180 |
return credentials
|
| 181 |
|
| 182 |
+
# Fallback to message data
|
| 183 |
logger.warning(f"MongoDB credentials incomplete for call_id {call_id}, using message fallback")
|
| 184 |
|
| 185 |
extra_params = fallback_msg.get("extraParams", {})
|
| 186 |
custom_field = fallback_msg.get("custom_field", {})
|
| 187 |
|
|
|
|
| 188 |
agent_id = (extra_params.get("platform_agent_id") or
|
| 189 |
custom_field.get("agentId") or
|
| 190 |
fallback_msg.get("agentId"))
|
| 191 |
|
|
|
|
| 192 |
public_key = (extra_params.get("publicKey") or extra_params.get("public_key") or
|
| 193 |
custom_field.get("publicKey") or custom_field.get("public_key") or
|
| 194 |
fallback_msg.get("publicKey") or fallback_msg.get("public_key"))
|
|
|
|
| 199 |
"metadata": {}
|
| 200 |
}
|
| 201 |
|
| 202 |
+
logger.warning(f"Using fallback credentials for call_id {call_id}")
|
| 203 |
return fallback_credentials
|
| 204 |
|
| 205 |
# ---------------------------------------------------------------------------#
|
| 206 |
+
# SIMPLIFIED AUDIO PROCESSOR #
|
| 207 |
# ---------------------------------------------------------------------------#
|
| 208 |
class RealTimeAudioProcessor:
|
| 209 |
"""
|
| 210 |
+
Simplified audio processor that directly forwards 16kHz PCM audio
|
| 211 |
+
between SAN system and Millis AI without buffering or conversion.
|
| 212 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
def __init__(self, agent_id: str, public_key: str, metadata: dict = None):
|
| 215 |
self.agent_id = agent_id
|
|
|
|
| 217 |
self.metadata = metadata or {}
|
| 218 |
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
| 219 |
self.connected = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
self.is_paused = False
|
| 221 |
self.stream_id: Optional[str] = None
|
| 222 |
self.call_id: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
| 223 |
self._packet_counter = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
async def connect(self) -> bool:
|
| 226 |
logger.info(f"Connecting to Millis AI for call {self.call_id}...")
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
try:
|
| 229 |
logger.info("Establishing WebSocket connection...")
|
|
|
|
| 241 |
"include_metadata_in_prompt": True
|
| 242 |
}
|
| 243 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
+
logger.info(f"Sending initiate payload to Millis AI")
|
| 246 |
await self.ws.send(json.dumps(initiate_payload))
|
|
|
|
| 247 |
|
| 248 |
logger.info("Waiting for Millis AI response...")
|
| 249 |
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
|
|
|
|
| 250 |
|
| 251 |
try:
|
| 252 |
parsed_msg = json.loads(msg)
|
|
|
|
| 253 |
method = parsed_msg.get("method")
|
|
|
|
| 254 |
|
| 255 |
if method != "onready":
|
| 256 |
logger.error(f"Expected 'onready' method, got '{method}'")
|
|
|
|
| 262 |
|
| 263 |
except json.JSONDecodeError as e:
|
| 264 |
logger.error(f"Failed to parse response as JSON: {e}")
|
|
|
|
| 265 |
raise
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
except Exception as e:
|
| 268 |
logger.error(f"Millis AI connection failed: {type(e).__name__}: {e}")
|
| 269 |
self.connected = False
|
|
|
|
| 276 |
self.ws = None
|
| 277 |
logger.info("Disconnected from Millis AI.")
|
| 278 |
|
| 279 |
+
async def _forward_to_millis(self, audio_data: bytes):
|
| 280 |
+
"""Forward audio data directly to Millis AI."""
|
| 281 |
+
if not self.connected or not self.ws:
|
| 282 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
try:
|
| 285 |
+
await self.ws.send(audio_data)
|
| 286 |
+
self._packet_counter += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
+
if self._packet_counter >= 1000:
|
| 289 |
+
logger.info("Sending ping to Millis AI")
|
| 290 |
+
await self.ws.send(json.dumps({"method": "ping"}))
|
| 291 |
+
self._packet_counter = 0
|
|
|
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
except Exception as e:
|
| 294 |
+
logger.error(f"Error forwarding audio to Millis: {e}")
|
| 295 |
+
self.connected = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
+
async def _handle_millis_messages(self, client_ws: WebSocket):
|
| 298 |
+
"""Handle messages from Millis AI and forward audio to SAN."""
|
| 299 |
+
logger.info(f"Starting Millis message handler for call {self.call_id}")
|
| 300 |
+
|
| 301 |
while self.connected and self.ws and self.ws.state == WsState.OPEN:
|
| 302 |
try:
|
| 303 |
msg = await self.ws.recv()
|
| 304 |
+
|
| 305 |
if isinstance(msg, bytes):
|
| 306 |
+
# Direct audio data - forward to SAN if not paused
|
| 307 |
+
if not self.is_paused:
|
| 308 |
+
await self._forward_to_san(client_ws, msg)
|
|
|
|
|
|
|
|
|
|
| 309 |
continue
|
| 310 |
|
| 311 |
# Handle JSON messages
|
|
|
|
| 313 |
evt = json.loads(msg)
|
| 314 |
except json.JSONDecodeError as e:
|
| 315 |
logger.warning(f"Failed to parse JSON message from Millis AI: {e}")
|
|
|
|
| 316 |
continue
|
| 317 |
|
| 318 |
method = evt.get("method")
|
|
|
|
| 319 |
if method not in ["ping", "pong"]:
|
| 320 |
+
logger.info(f"Millis event: {method}")
|
| 321 |
+
|
| 322 |
if method == "pause":
|
| 323 |
self.is_paused = True
|
| 324 |
logger.info("Audio paused")
|
|
|
|
|
|
|
| 325 |
elif method == "unpause":
|
| 326 |
self.is_paused = False
|
| 327 |
logger.info("Audio unpaused")
|
| 328 |
elif method in ("clear", "start_answering"):
|
|
|
|
| 329 |
self.is_paused = False
|
| 330 |
+
logger.info("Audio cleared/started")
|
| 331 |
+
|
| 332 |
except websockets.exceptions.ConnectionClosed:
|
| 333 |
logger.warning("Millis AI closed the connection.")
|
| 334 |
self.connected = False
|
| 335 |
except Exception as e:
|
| 336 |
+
logger.error(f"Error in Millis message handler: {e}")
|
|
|
|
| 337 |
self.connected = False
|
| 338 |
|
| 339 |
+
async def _forward_to_san(self, client_ws: WebSocket, audio_data: bytes):
|
| 340 |
+
"""Forward audio data to SAN system."""
|
| 341 |
+
try:
|
| 342 |
+
payload = base64.b64encode(audio_data).decode()
|
| 343 |
+
|
| 344 |
+
reverse_media_payload = {
|
| 345 |
+
"event": "reverse-media",
|
| 346 |
+
"callid": self.call_id,
|
| 347 |
+
"payload": payload,
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
await client_ws.send_json(reverse_media_payload)
|
| 351 |
+
|
| 352 |
+
except Exception as e:
|
| 353 |
+
logger.error(f"Error forwarding audio to SAN: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
async def start(self, client_ws: WebSocket) -> list[asyncio.Task]:
|
| 356 |
+
"""Start the audio processor."""
|
| 357 |
logger.info(f"Starting RealTimeAudioProcessor for call {self.call_id}")
|
| 358 |
+
|
| 359 |
if not await self.connect():
|
| 360 |
logger.error("Failed to connect to Millis AI")
|
| 361 |
return []
|
| 362 |
|
| 363 |
+
logger.info("Creating audio processing task")
|
| 364 |
tasks = [
|
| 365 |
+
asyncio.create_task(self._handle_millis_messages(client_ws)),
|
|
|
|
|
|
|
| 366 |
]
|
| 367 |
+
|
| 368 |
logger.info(f"Created {len(tasks)} tasks")
|
| 369 |
return tasks
|
| 370 |
|
|
|
|
| 390 |
try:
|
| 391 |
while True:
|
| 392 |
raw = await ws.receive_text()
|
|
|
|
| 393 |
msg = json.loads(raw)
|
| 394 |
event = msg.get("event")
|
| 395 |
|
|
|
|
| 396 |
if event == "start":
|
| 397 |
logger.info("=== START EVENT ===")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
new_call_id = msg.get("callId")
|
| 399 |
stream_id = msg.get("streamId")
|
| 400 |
+
logger.info(f"Call ID: {new_call_id}, Stream ID: {stream_id}")
|
| 401 |
|
| 402 |
if processor and new_call_id != active_call_id:
|
| 403 |
+
logger.info(f"New call detected. Stopping old processor.")
|
| 404 |
await stop_processor(processor, tasks)
|
| 405 |
processor, tasks = None, []
|
| 406 |
|
| 407 |
if processor is None:
|
| 408 |
logger.info(f"Starting processor for call: {new_call_id}")
|
| 409 |
|
| 410 |
+
# Extract credentials
|
|
|
|
| 411 |
extra_params = msg.get("extraParams", {})
|
|
|
|
|
|
|
| 412 |
agent_id = extra_params.get("platform_agent_id")
|
|
|
|
|
|
|
| 413 |
public_key = PUBLIC_KEY
|
|
|
|
| 414 |
|
| 415 |
+
if not agent_id or not public_key:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
credentials = await fetch_call_credentials_with_fallback(new_call_id, msg)
|
| 417 |
+
agent_id = agent_id or credentials.get("platform_agent_id")
|
| 418 |
+
public_key = public_key or credentials.get("public_key")
|
|
|
|
|
|
|
| 419 |
|
| 420 |
if not agent_id:
|
| 421 |
logger.error(f"No agent_id found for call_id: {new_call_id}")
|
|
|
|
| 422 |
await ws.close(code=1008, reason="Missing agent_id")
|
| 423 |
return
|
| 424 |
|
| 425 |
if not public_key:
|
| 426 |
+
logger.error("No public_key found")
|
|
|
|
| 427 |
await ws.close(code=1008, reason="Missing public_key")
|
| 428 |
return
|
| 429 |
|
| 430 |
+
# Get metadata from MongoDB
|
|
|
|
|
|
|
| 431 |
mongodb_call_id = extra_params.get("call_id")
|
|
|
|
| 432 |
if mongodb_call_id:
|
|
|
|
| 433 |
mongodb_credentials = await fetch_call_credentials(mongodb_call_id)
|
| 434 |
metadata = mongodb_credentials.get("metadata", {})
|
| 435 |
+
metadata.update(extra_params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
else:
|
| 437 |
+
metadata = extra_params
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
|
| 439 |
+
logger.info(f"Creating processor with agent_id: {agent_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
processor = RealTimeAudioProcessor(agent_id, public_key, metadata)
|
| 441 |
processor.stream_id = stream_id
|
| 442 |
processor.call_id = new_call_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
|
|
|
|
| 444 |
tasks = await processor.start(ws)
|
| 445 |
if not tasks:
|
| 446 |
logger.error("Failed to start processor")
|
| 447 |
await ws.close(code=1011, reason="Could not connect to AI backend.")
|
| 448 |
return
|
| 449 |
active_call_id = new_call_id
|
| 450 |
+
logger.info("=== START EVENT COMPLETED ===")
|
| 451 |
continue
|
| 452 |
|
| 453 |
elif event == "media" and processor:
|
| 454 |
payload_b64 = msg.get("payload")
|
| 455 |
if payload_b64:
|
| 456 |
+
# Decode and directly forward audio data to Millis AI
|
| 457 |
+
pcm_data = base64.b64decode(payload_b64)
|
| 458 |
+
await processor._forward_to_millis(pcm_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
continue
|
| 460 |
|
| 461 |
elif event in ("hangup", "stop", "disconnect"):
|
|
|
|
| 473 |
|
| 474 |
except WebSocketDisconnect:
|
| 475 |
logger.info("=== WEBSOCKET DISCONNECT ===")
|
|
|
|
| 476 |
except Exception as e:
|
|
|
|
| 477 |
logger.error(f"Unhandled error in media_socket: {e}", exc_info=True)
|
| 478 |
finally:
|
| 479 |
logger.info("=== FINAL CLEANUP ===")
|
|
|
|
| 489 |
async def health():
|
| 490 |
logger.info("Health check endpoint called")
|
| 491 |
|
|
|
|
| 492 |
mongodb_status = "connected" if mongodb_client is not None and mongodb_db is not None else "disconnected"
|
| 493 |
|
| 494 |
return {
|
|
|
|
| 500 |
"collection": MONGODB_COLLECTION or "not_set"
|
| 501 |
},
|
| 502 |
"config": {
|
|
|
|
| 503 |
"public_key": PUBLIC_KEY[:10] + "..." if PUBLIC_KEY else "not_set",
|
| 504 |
"millis_ws_uri": MILLIS_WS_URI
|
| 505 |
}
|
|
|
|
| 512 |
|
| 513 |
@app.get("/test-mongodb")
|
| 514 |
async def test_mongodb():
|
| 515 |
+
"""Test MongoDB connectivity."""
|
| 516 |
logger.info("MongoDB test endpoint called")
|
| 517 |
|
| 518 |
if mongodb_db is None:
|
|
|
|
| 521 |
try:
|
| 522 |
collection = mongodb_db[MONGODB_COLLECTION]
|
| 523 |
|
|
|
|
| 524 |
test_doc = {
|
| 525 |
"call_id": "test-call-123",
|
| 526 |
"platform_agent_id": "test-agent-456",
|
|
|
|
| 529 |
}
|
| 530 |
|
| 531 |
result = await collection.insert_one(test_doc)
|
|
|
|
|
|
|
|
|
|
| 532 |
retrieved = await collection.find_one({"call_id": "test-call-123"})
|
| 533 |
|
| 534 |
return {
|
|
|
|
| 547 |
"mongodb_connected": mongodb_db is not None
|
| 548 |
}
|
| 549 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
if __name__ == "__main__":
|
| 551 |
+
print("Starting SAN to Millis AI Integration Server (v6 - Simplified)...")
|
| 552 |
logger.info("=== SERVER STARTING ===")
|
| 553 |
logger.info(f"Public Key: {PUBLIC_KEY[:10] + '...' if PUBLIC_KEY else 'NOT_SET'}")
|
| 554 |
logger.info(f"Millis URI: {MILLIS_WS_URI}")
|
|
|
|
|
|
|
|
|
|
| 555 |
logger.info("=== SERVER READY ===")
|
| 556 |
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
|