Spaces:
Runtime error
Runtime error
File size: 20,270 Bytes
24cdea4 9b15ee7 24cdea4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 | #
# Copyright (c) 2025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""
Pipecat Voice AI Bot - Main entry point.
This module orchestrates the voice AI pipeline with:
- Real-time audio processing (STT → LLM → TTS)
- RTVI protocol for client communication
- Flow management for conversation state
- Transcript and analytics collection
Supports multiple transports:
- Daily.co (WebRTC/RTVI)
- Twilio (Media Streams WebSocket)
"""
import asyncio
import os
from typing import Any, Optional
import sentry_sdk
from dotenv import load_dotenv
# Langfuse tracing
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from pipecat.utils.tracing.setup import setup_tracing
from pipecat.pipeline.runner import PipelineRunner
# Local modules
from bot_schemas import BotData
from bot_config import BotConfig, TransportType
from services import create_llm_service, create_tts_service, create_stt_service, create_transport, create_livekit_transport
from utils.postgres import postgres
from core.pipeline import PipelineBuilder
from core.rtvi import setup_rtvi_processor, setup_client_ready_handler
from core.events import setup_event_handlers
from core.flow import end_conversation_handler, process_flow_config_handlers
from core.transcript import TranscriptCollector, TranscriptObserver
from local.recording import create_recording_processors
from utils.logging import log_info, log_warning, log_error
from fastapi import WebSocket
from metrics import (
capture_event,
get_default_provider,
create_llm_observer,
create_latency_observer,
)
from pipecat_flows import FlowConfig, FlowManager
from pipecatcloud.agent import DailySessionArguments, WebSocketSessionArguments
load_dotenv(override=True)
def create_transcript_handlers(log_key: str, enabled: bool = True):
"""Create transcript handlers for collecting conversation."""
if not enabled:
log_info(log_key, "Transcript collection disabled")
return None, []
transcript_collector = TranscriptCollector()
transcript_observer = TranscriptObserver(transcript_collector)
log_info(log_key, "Transcript collection enabled")
return transcript_collector, [transcript_observer]
def setup_tracing_if_enabled(log_key: str) -> None:
"""Configure Langfuse tracing if enabled via environment."""
if os.getenv("ENABLE_TRACING", "false").lower() != "true":
return
try:
otlp_exporter = OTLPSpanExporter()
setup_tracing(
service_name="pipecat-bot",
exporter=otlp_exporter,
console_export=os.getenv("OTEL_CONSOLE_EXPORT", "false").lower() == "true",
)
log_info(log_key, "Langfuse tracing configured")
except Exception as e:
log_error(log_key, f"Failed to configure tracing: {e}")
def setup_sentry(environment: str) -> None:
"""Initialize Sentry error tracking (optional)."""
sentry_dsn = os.environ.get("SENTRY_DSN")
if not sentry_dsn:
return # Sentry is optional
sentry_sdk.init(
dsn=sentry_dsn,
traces_sample_rate=1.0,
environment=environment,
)
async def setup_flow_manager(
payload: BotData,
rtvi_client: Any,
task: Any,
llm: Any,
context_aggregator: Any,
log_key: str,
) -> Optional[FlowManager]:
"""Set up FlowManager for conversation flow."""
if not payload.flow:
log_error(log_key, "No flow provided")
return None
flow_config = payload.flow.model_dump()
flow_config_obj = process_flow_config_handlers(
FlowConfig(**flow_config),
rtvi_client,
)
flow_manager = FlowManager(
task=task,
llm=llm,
context_aggregator=context_aggregator,
flow_config=flow_config_obj,
)
flow_manager.log_key = log_key
flow_manager.register_action("end_conversation", end_conversation_handler)
# Handle legacy initial_node attribute
if not getattr(flow_manager, "initial_node", None):
legacy_initial = getattr(flow_manager, "_initial_node", None)
if legacy_initial:
setattr(flow_manager, "initial_node", legacy_initial)
return flow_manager
async def enforce_max_session_duration(timeout_seconds: int, task: Any, log_key: str):
"""Cancel task after max session duration."""
try:
await asyncio.sleep(timeout_seconds)
log_warning(log_key, f"Max session duration ({timeout_seconds}s) reached")
await task.cancel()
except asyncio.CancelledError:
pass
async def run_pipeline(config: BotConfig) -> dict:
"""
Unified pipeline for all transport types.
Returns dict with session results (transcript, duration, etc.)
"""
result = {
"transcript_messages": [],
"duration": 0,
"status": "COMPLETED",
}
# Parse bot data
if config.bot_data:
data = BotData(**config.bot_data) if isinstance(config.bot_data, dict) else config.bot_data
elif config.agent_id:
# Load from database
bot_data_dict = await postgres.get_bot_data(config.agent_id)
if not bot_data_dict:
raise ValueError(f"Failed to load bot data for agent: {config.agent_id}")
data = BotData(**bot_data_dict)
else:
raise ValueError("Either bot_data or agent_id must be provided")
# Extract configuration
transcript_key = data.metadata.transcript_key if hasattr(data.metadata, 'transcript_key') else None
environment = data.metadata.environment or "production"
user_id = data.metadata.user_id or config.caller_number or f"user-{config.session_id}"
max_duration = data.metadata.max_duration or 3600
# Create log key
if config.transport_type == TransportType.TWILIO:
log_key = f"twilio_{config.call_sid or config.session_id or 'unknown'}"
else:
log_key = transcript_key.replace("/", "_") if transcript_key else config.session_id or "unknown-session"
log_info(log_key, f"Pipeline started [transport={config.transport_type.value}, env={environment}]")
# Setup infrastructure
setup_tracing_if_enabled(log_key)
setup_sentry(environment)
# Create or use provided transport
if config.transport_type == TransportType.TWILIO:
if not config.transport:
raise ValueError("Twilio transport must be provided via config.transport")
transport = config.transport
rtvi = None # No RTVI for Twilio
elif config.transport_type == TransportType.LIVEKIT:
# LiveKit Cloud transport
if not config.livekit_url or not config.livekit_token:
raise ValueError("livekit_url and livekit_token required for LiveKit transport")
transport = create_livekit_transport(
room_url=config.livekit_url,
token=config.livekit_token,
room_name=config.livekit_room_name or "",
participant_identity=config.participant_identity,
)
rtvi = None # RTVI not supported with LiveKit yet
log_info(log_key, f"LiveKit transport created [identity={config.participant_identity}]")
else:
# Daily.co transport
if not config.room_url or not config.token:
raise ValueError("room_url and token required for Daily transport")
transport = create_transport(config.room_url, config.token, config.bot_name)
rtvi = None
if config.rtvi_enabled:
rtvi = setup_rtvi_processor(
pipeline_getter=lambda: pipeline,
log_func=lambda msg: log_info(log_key, msg),
)
# Create services with provider selection
llm = create_llm_service(
provider=data.config.llm_provider.value,
model=data.config.model,
)
tts = create_tts_service(
provider=data.config.tts_provider.value,
voice_id=data.config.voice_id,
)
stt = create_stt_service(
provider=data.config.stt_provider.value,
)
log_info(
log_key,
f"Services initialized [llm={data.config.llm_provider.value}/{data.config.model}, "
f"tts={data.config.tts_provider.value}, stt={data.config.stt_provider.value}]"
)
# Setup recording (local only for now)
audio_processor = None
if config.local_recording:
audio_processor = create_recording_processors(config.session_id, config.recording_dir)
log_info(log_key, f"Local recording mode [dir={config.recording_dir}]")
# Setup transcript collection - always enabled for Twilio to capture messages
enable_transcript = config.transport_type == TransportType.TWILIO or transcript_key is not None
transcript_collector, transcript_observers = create_transcript_handlers(log_key, enable_transcript)
# Build pipeline
pipeline = None
builder = (
PipelineBuilder()
.with_transport(transport)
.with_rtvi(rtvi)
.with_stt(stt)
.with_llm(llm)
.with_tts(tts)
.with_recording(audio_processor)
.with_transcript()
)
pipeline = builder.build()
# Build observers
llm_observer = None
latency_observer = None
if config.transport_type == TransportType.DAILY:
llm_observer = create_llm_observer(data, llm.model_name)
latency_observer = create_latency_observer(data, rtvi if config.rtvi_enabled else None)
observers = builder.build_observers(transcript_observers, llm_observer, latency_observer)
# Create task
task = builder.create_task(pipeline, observers, log_key, user_id, environment)
# Setup flow manager
flow_manager = await setup_flow_manager(
data, rtvi, task, llm, builder.context_aggregator, log_key
)
if not flow_manager:
log_error(log_key, "Failed to initialize FlowManager")
result["status"] = "FAILED"
return result
log_info(log_key, "FlowManager initialized")
# Setup max session duration
log_info(log_key, f"Max duration: {max_duration}s")
asyncio.create_task(enforce_max_session_duration(max_duration, task, log_key))
# Setup event handlers based on transport type
if config.transport_type == TransportType.TWILIO:
# For Twilio, setup simplified event handlers
setup_twilio_event_handlers(
transport=transport,
task=task,
flow_manager=flow_manager,
transcript_processor=builder.transcript_processor,
transcript_collector=transcript_collector,
log_key=log_key,
)
elif config.transport_type == TransportType.LIVEKIT:
# LiveKit event handlers
setup_livekit_event_handlers(
transport=transport,
task=task,
flow_manager=flow_manager,
transcript_processor=builder.transcript_processor,
transcript_collector=transcript_collector,
log_key=log_key,
)
else:
# Daily.co event handlers
if config.rtvi_enabled and rtvi:
setup_client_ready_handler(rtvi, lambda msg: log_info(log_key, msg))
setup_event_handlers(
transport=transport,
task=task,
flow_manager=flow_manager,
rtvi=rtvi,
transcript=builder.transcript_processor,
transcript_collector=transcript_collector,
audio_processor=audio_processor,
config=config,
log_key=log_key,
capture_event_func=capture_event,
bot_data=data,
)
# Run pipeline
runner = PipelineRunner()
try:
await runner.run(task)
log_info(log_key, "Pipeline completed successfully")
result["status"] = "COMPLETED"
except Exception as e:
log_error(log_key, f"Pipeline failed: {e}")
result["status"] = "FAILED"
if config.transport_type == TransportType.DAILY:
capture_event(data, "pipecat_error_encountered", {"message": str(e)})
raise
finally:
# Collect transcript messages
if transcript_collector:
result["transcript_messages"] = transcript_collector.get_messages()
if config.transport_type == TransportType.DAILY:
get_default_provider().close()
log_info(log_key, "Pipeline finished")
return result
def setup_twilio_event_handlers(
transport: Any,
task: Any,
flow_manager: Any,
transcript_processor: Any,
transcript_collector: Optional[TranscriptCollector],
log_key: str,
) -> None:
"""
Setup event handlers for Twilio transport.
Twilio doesn't have participant join/leave events like Daily.co,
so we handle the flow differently.
"""
from datetime import datetime, timezone
# Initialize flow immediately when transport is ready
@transport.event_handler("on_client_connected")
async def handle_client_connected(transport_instance, websocket):
log_info(log_key, "Twilio client connected")
# Start transcript session
if transcript_collector:
recording_start_time = datetime.now(timezone.utc).isoformat()
transcript_collector.start_session(recording_start_time)
# Initialize flow
await flow_manager.initialize()
# Set initial node
if flow_manager.initial_node:
from core.flow import send_state_sync
await send_state_sync(flow_manager, flow_manager.initial_node)
@transport.event_handler("on_client_disconnected")
async def handle_client_disconnected(transport_instance, websocket):
log_info(log_key, "Twilio client disconnected")
# Finalize transcript
if transcript_collector:
transcript_collector.finalize()
# Cancel task
await task.cancel()
# Handle transcript updates
if transcript_processor:
@transcript_processor.event_handler("on_transcript_update")
async def handle_transcript_update(processor, frame):
if transcript_collector:
for message in frame.messages:
transcript_collector.process_utterance(message.role, message.content)
def setup_livekit_event_handlers(
transport: Any,
task: Any,
flow_manager: Any,
transcript_processor: Any,
transcript_collector: Optional[TranscriptCollector],
log_key: str,
) -> None:
"""
Setup event handlers for LiveKit transport.
LiveKit has similar event model to Daily.co but with different event names.
"""
from datetime import datetime, timezone
@transport.event_handler("on_participant_connected")
async def handle_participant_connected(transport_instance, participant):
log_info(log_key, f"LiveKit participant connected: {participant.identity if hasattr(participant, 'identity') else participant}")
# Start transcript session
if transcript_collector:
recording_start_time = datetime.now(timezone.utc).isoformat()
transcript_collector.start_session(recording_start_time)
# Initialize flow
await flow_manager.initialize()
# Set initial node
if flow_manager.initial_node:
from core.flow import send_state_sync
await send_state_sync(flow_manager, flow_manager.initial_node)
@transport.event_handler("on_participant_disconnected")
async def handle_participant_disconnected(transport_instance, participant):
log_info(log_key, f"LiveKit participant disconnected: {participant.identity if hasattr(participant, 'identity') else participant}")
# Finalize transcript
if transcript_collector:
transcript_collector.finalize()
# Cancel task
await task.cancel()
# Handle transcript updates if processor is available
if transcript_processor:
@transcript_processor.event_handler("on_transcript_update")
async def handle_transcript_update(processor, frame):
if transcript_collector:
for message in frame.messages:
transcript_collector.process_utterance(message.role, message.content)
async def run_livekit_pipeline(config: BotConfig) -> dict:
"""
Entry point for LiveKit bot sessions.
This is called by the LiveKit runner to start a bot with LiveKit transport.
"""
log_info(config.session_id or "livekit", f"Starting LiveKit pipeline [url={config.livekit_url}]")
return await run_pipeline(config)
# -----------------------------------------------------------------------------
# Entry Points
# -----------------------------------------------------------------------------
async def bot(args):
"""
Main bot entry point for Pipecat Cloud.
Handles both transport types:
- DailySessionArguments: WebRTC sessions via Daily.co
- WebSocketSessionArguments: Twilio Media Streams via WebSocket
"""
# Check argument type to determine transport
if isinstance(args, WebSocketSessionArguments):
# Twilio WebSocket transport
log_info("", f"Twilio WebSocket session started [session_id={args.session_id}]")
try:
from services.twilio_transport import handle_twilio_call
# Pass session_id to enable Pipecat Cloud logs retrieval
await handle_twilio_call(args.websocket, run_twilio_pipeline_wrapper, args.session_id)
log_info("", "Twilio WebSocket session completed")
except Exception as e:
log_error("", f"Twilio WebSocket session error: {str(e)}")
raise
elif isinstance(args, DailySessionArguments):
# Daily.co WebRTC transport
log_info("", f"Bot initialized [room={args.room_url}]")
if not args.body:
raise ValueError("No data provided")
try:
session_id = args.session_id if hasattr(args, "session_id") else None
config = BotConfig(
transport_type=TransportType.DAILY,
session_id=session_id,
room_url=args.room_url,
token=args.token,
bot_data=args.body,
)
await run_pipeline(config)
log_info(None, "Bot completed")
except Exception as e:
log_error(None, f"Bot error: {str(e)}")
raise
else:
raise TypeError(f"Unsupported session arguments type: {type(args)}")
async def run_twilio_pipeline_wrapper(
transport: Any,
agent_config: dict,
context: Any, # TwilioCallContext
) -> None:
"""
Wrapper to run unified pipeline for Twilio calls.
This bridges the old TwilioCallContext API with the new unified pipeline.
"""
log_key = f"twilio_{context.call_sid}"
# Load botData from agent config
bot_data_json = agent_config.get("botData")
if bot_data_json:
log_info(log_key, "Using botData from database")
bot_data = bot_data_json
else:
# Fallback to legacy generation
log_warning(log_key, "No botData in database, using legacy generation")
bot_data = postgres._generate_bot_data_legacy(agent_config)
# Create unified config
config = BotConfig(
transport_type=TransportType.TWILIO,
session_id=context.pipecat_session_id,
call_sid=context.call_sid,
stream_sid=context.stream_sid,
caller_number=context.caller_number,
agent_id=context.agent_id,
bot_data=bot_data,
transport=transport,
rtvi_enabled=False, # No RTVI for Twilio
)
# Run unified pipeline
result = await run_pipeline(config)
# Update context with results for database save
if result.get("transcript_messages"):
context.transcript_messages = result["transcript_messages"]
async def twilio_websocket_handler(websocket: WebSocket) -> None:
"""
FastAPI WebSocket endpoint handler for Twilio Media Streams.
This is for local development. Mount this in your FastAPI app:
@app.websocket("/twilio")
async def twilio_ws(websocket: WebSocket):
await twilio_websocket_handler(websocket)
"""
from services.twilio_transport import handle_twilio_call
await handle_twilio_call(websocket, run_twilio_pipeline_wrapper)
# Legacy alias for backwards compatibility
main = run_pipeline
|