cartesia-line 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cartesia-line might be problematic. Click here for more details.

@@ -0,0 +1,147 @@
1
+ from datetime import datetime, timezone
2
+ import json
3
+ import logging
4
+ import os
5
+ from typing import Awaitable, Callable, Optional
6
+ from urllib.parse import urlencode
7
+
8
+ from dotenv import load_dotenv
9
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
10
+ import uvicorn
11
+
12
+ from line.call_request import CallRequest, PreCallResult
13
+ from line.voice_agent_system import VoiceAgentSystem
14
+
15
+ # Load environment variables from .env file
16
+ load_dotenv()
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class VoiceAgentApp:
22
+ """
23
+ VoiceAgentApp (name tbd) abstracts away the HTTP and websocket handling,
24
+ which should be invisible to developers, because this transport may change
25
+ in the future (eg to WebRTC).
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ call_handler,
31
+ pre_call_handler: Optional[Callable[[CallRequest], Awaitable[Optional[PreCallResult]]]] = None,
32
+ ):
33
+ self.fastapi_app = FastAPI()
34
+ self.call_handler = call_handler
35
+ self.pre_call_handler = pre_call_handler
36
+ self.ws_route = "/ws"
37
+
38
+ self.fastapi_app.add_api_route("/chats", self.create_chat_session, methods=["POST"])
39
+ self.fastapi_app.add_api_route("/status", self.get_status, methods=["GET"])
40
+ self.fastapi_app.add_websocket_route(self.ws_route, self.websocket_endpoint)
41
+
42
+ async def create_chat_session(self, request: Request) -> dict:
43
+ """Create a new chat session and return the websocket URL."""
44
+ # Parse JSON body
45
+ body = await request.json()
46
+
47
+ # Create initial CallRequest
48
+ call_request = CallRequest(
49
+ call_id=body.get("call_id", "unknown"),
50
+ from_=body.get("from_", "unknown"),
51
+ to=body.get("to", "unknown"),
52
+ agent_call_id=body.get("agent_call_id", body.get("call_id", "unknown")),
53
+ metadata=body.get("metadata", {}),
54
+ )
55
+
56
+ # Run pre-call handler if provided
57
+ config = None
58
+ if self.pre_call_handler:
59
+ try:
60
+ result = await self.pre_call_handler(call_request)
61
+ if result is None:
62
+ raise HTTPException(status_code=403, detail="Call rejected")
63
+
64
+ # Update call_request metadata with result
65
+ call_request.metadata.update(result.metadata)
66
+ config = result.config
67
+
68
+ except HTTPException:
69
+ raise
70
+ except Exception as e:
71
+ logger.error(f"Error in pre_call_handler: {str(e)}")
72
+ raise HTTPException(status_code=500, detail="Server error in call processing") from e
73
+
74
+ # Create URL parameters from processed call_request
75
+ url_params = {
76
+ "call_id": call_request.call_id,
77
+ "from": call_request.from_,
78
+ "to": call_request.to,
79
+ "agent_call_id": call_request.agent_call_id,
80
+ "metadata": json.dumps(call_request.metadata), # JSON encode metadata
81
+ }
82
+
83
+ # Build websocket URL with parameters
84
+ query_string = urlencode(url_params)
85
+ websocket_url = f"{self.ws_route}?{query_string}"
86
+
87
+ response = {"websocket_url": websocket_url}
88
+ if config:
89
+ response["config"] = config
90
+ return response
91
+
92
+ async def get_status(self) -> dict:
93
+ """Status endpoint that returns OK if the server is running."""
94
+ logger.info("Health check endpoint called - voice agent is ready 🤖✅")
95
+ return {
96
+ "status": "ok",
97
+ "timestamp": datetime.now(timezone.utc).isoformat(),
98
+ "service": "cartesia-line",
99
+ }
100
+
101
+ async def websocket_endpoint(self, websocket: WebSocket):
102
+ """Websocket endpoint that manages the complete call lifecycle."""
103
+ await websocket.accept()
104
+ logger.info("Client connected")
105
+
106
+ # Parse query parameters from WebSocket URL
107
+ query_params = dict(websocket.query_params)
108
+
109
+ # Parse metadata JSON
110
+ metadata = {}
111
+ if "metadata" in query_params:
112
+ try:
113
+ metadata = json.loads(query_params["metadata"])
114
+ except (json.JSONDecodeError, TypeError):
115
+ logger.warning(f"Invalid metadata JSON: {query_params['metadata']}")
116
+ metadata = {}
117
+
118
+ # Create CallRequest from URL parameters
119
+ call_request = CallRequest(
120
+ call_id=query_params.get("call_id", "unknown"),
121
+ from_=query_params.get("from", "unknown"),
122
+ to=query_params.get("to", "unknown"),
123
+ agent_call_id=query_params.get("agent_call_id", "unknown"),
124
+ metadata=metadata,
125
+ )
126
+
127
+ system = VoiceAgentSystem(websocket)
128
+
129
+ try:
130
+ # Handler configures nodes and bridges, then starts system
131
+ await self.call_handler(system, call_request)
132
+ except WebSocketDisconnect:
133
+ logger.info("Client disconnected")
134
+ except Exception as e:
135
+ logger.exception(f"Error: {str(e)}")
136
+ try:
137
+ await system.harness.send_error("System has encountered an error, please try again later.")
138
+ await system.harness.end_call()
139
+ except: # noqa: E722
140
+ pass
141
+ finally:
142
+ await system.cleanup()
143
+
144
+ def run(self, host="0.0.0.0", port=None):
145
+ """Run the voice agent server."""
146
+ port = port or int(os.getenv("PORT", 8000))
147
+ uvicorn.run(self.fastapi_app, host=host, port=port)
@@ -0,0 +1,230 @@
1
+ """
2
+ VoiceAgentSystem
3
+
4
+ A utility for building voice agent systems.
5
+
6
+ - Manages bus and harness setup automatically.
7
+ - Lets you add and customize components easily.
8
+ """
9
+
10
+ import sys
11
+ from typing import Dict, Optional
12
+
13
+ from loguru import logger
14
+
15
+ from line.bridge import Bridge
16
+ from line.bus import Bus, Message
17
+ from line.events import AgentResponse
18
+ from line.harness import ConversationHarness
19
+ from line.nodes.reasoning import ReasoningNode
20
+ from line.user_bridge import create_user_bridge
21
+
22
+ # Configure logging to filter out DEBUG messages from line modules
23
+ logger.configure(
24
+ handlers=[
25
+ {
26
+ "sink": sys.stderr,
27
+ "filter": lambda record: not (
28
+ record["name"].startswith("line") and record["level"].name == "DEBUG"
29
+ ),
30
+ }
31
+ ]
32
+ )
33
+
34
+
35
+ class VoiceAgentSystem:
36
+ """
37
+ System builder for voice agent applications.
38
+
39
+ Automatically manages Bus and ConversationHarness lifecycle
40
+ while providing fluent API for adding bridges and components.
41
+ """
42
+
43
+ def __init__(self, websocket):
44
+ """
45
+ Create voice agent system with websocket.
46
+
47
+ Args:
48
+ websocket: FastAPI WebSocket connection for voice communication.
49
+ """
50
+ self.websocket = websocket
51
+ self.bus = Bus()
52
+ self.harness = ConversationHarness(websocket, self.bus.shutdown_event)
53
+ self.bridges: Dict[str, Bridge] = {}
54
+ self.components = {} # Store nodes and other components.
55
+ self.authorized_node = None
56
+ self.main_node: Optional[ReasoningNode] = None # Track the main reasoning node.
57
+
58
+ self._add_logging_bridge()
59
+
60
+ def _add_logging_bridge(self):
61
+ """Add a bridge that logs all messages to the console."""
62
+ bridge = Bridge("logging")
63
+
64
+ def log_message(msg):
65
+ logger.debug(f"Logger Bridge:\n\n{msg}")
66
+
67
+ bridge.on("*").map(log_message)
68
+ self.bridges["logging"] = bridge
69
+
70
+ def _setup_authorized_infrastructure(self, authorized_node: str):
71
+ """Setup user bridge for the authorized agent."""
72
+
73
+ # Auto-add user bridge for WebSocket communication.
74
+ user_bridge = create_user_bridge(self.harness, authorized_node)
75
+ self.bridges["user"] = user_bridge
76
+
77
+ def with_node(self, node: ReasoningNode, bridge: Optional[Bridge] = None):
78
+ """
79
+ Add reasoning node and optional bridge.
80
+
81
+ Args:
82
+ name: Node identifier.
83
+ node: Reasoning node instance.
84
+ bridge: Optional NodeBridge instance for the node.
85
+
86
+ Returns:
87
+ Self for method chaining.
88
+ """
89
+ self.components[node.id] = node
90
+
91
+ # Track the main/authorized node.
92
+ if self.authorized_node == node.id:
93
+ self.main_node = node
94
+ if bridge:
95
+ self.with_bridge(node.id, bridge)
96
+ return self
97
+
98
+ def with_speaking_node(self, node: ReasoningNode, bridge: Bridge) -> "VoiceAgentSystem":
99
+ """
100
+ Add the speaking reasoning node and optional bridge, setting it as the authorized agent.
101
+
102
+ Args:
103
+ node: Reasoning node instance.
104
+ bridge: Optional NodeBridge instance for the speaking node.
105
+
106
+ Returns:
107
+ Self for method chaining.
108
+ """
109
+ if self.authorized_node is None:
110
+ self.authorized_node = bridge.node_id
111
+ self._setup_authorized_infrastructure(authorized_node=bridge.node_id)
112
+ logger.info(f"VoiceAgentSystem: Set authorized agent to '{bridge.node_id}'")
113
+ elif self.authorized_node != bridge.node_id:
114
+ logger.warning(
115
+ f"VoiceAgentSystem: Authorized agent already set to '{self.authorized_node}', "
116
+ f"ignoring '{bridge.node_id}'"
117
+ )
118
+
119
+ result = self.with_node(node)
120
+ if bridge:
121
+ result = result.with_bridge(bridge.node_id, bridge)
122
+ return result
123
+
124
+ def with_main_bridge(self, bridge: Bridge) -> "VoiceAgentSystem":
125
+ """
126
+ Add the main bridge using the authorized agent name.
127
+
128
+ Args:
129
+ bridge: Configured NodeBridge instance.
130
+
131
+ Returns:
132
+ Self for method chaining.
133
+ """
134
+ if self.authorized_node is None:
135
+ raise ValueError("Must call with_main_node() before with_main_bridge()")
136
+ return self.with_bridge(self.authorized_node, bridge)
137
+
138
+ def with_bridge(self, name: str, bridge: Bridge) -> "VoiceAgentSystem":
139
+ """
140
+ Add bridge to the system.
141
+
142
+ Args:
143
+ name: Bridge identifier for bus registration.
144
+ bridge: Configured NodeBridge instance.
145
+
146
+ Returns:
147
+ Self for method chaining.
148
+ """
149
+ self.bridges[name] = bridge
150
+ return self
151
+
152
+ async def start(self):
153
+ """Start the voice agent system."""
154
+
155
+ # Register all bridges quietly first
156
+ for name, bridge in self.bridges.items():
157
+ logger.debug(f"VoiceAgentSystem: Registering bridge '{name}'")
158
+ self.bus.register_bridge(name, bridge)
159
+
160
+ # Initialize all reasoning nodes quietly
161
+ for name, component in self.components.items():
162
+ if hasattr(component, "start"):
163
+ logger.debug(f"VoiceAgentSystem: Starting component '{name}'")
164
+ await component.start()
165
+
166
+ # Start bus (which will show the summary) then other components
167
+ await self.bus.start()
168
+ await self.harness.start()
169
+
170
+ # Start all bridges quietly
171
+ for _, bridge in self.bridges.items():
172
+ await bridge.start()
173
+
174
+ logger.info("VoiceAgentSystem: All components started successfully")
175
+
176
+ async def cleanup(self):
177
+ """Clean shutdown of the voice agent system."""
178
+ logger.info("VoiceAgentSystem: Starting system cleanup")
179
+
180
+ # Stop bridges first.
181
+ logger.debug(f"VoiceAgentSystem: Stopping {len(self.bridges)} bridges")
182
+ for name, bridge in self.bridges.items():
183
+ logger.debug(f"VoiceAgentSystem: Stopping bridge '{name}'")
184
+ try:
185
+ await bridge.stop()
186
+ except Exception as e:
187
+ logger.error(f"VoiceAgentSystem: Error stopping bridge '{name}': {e}")
188
+ raise e
189
+
190
+ # Cleanup reasoning nodes.
191
+ logger.debug(f"VoiceAgentSystem: Cleaning up {len(self.components)} components")
192
+ for name, component in self.components.items():
193
+ if hasattr(component, "cleanup"):
194
+ logger.debug(f"VoiceAgentSystem: Cleaning up component '{name}'")
195
+ await component.cleanup()
196
+
197
+ # Then stop infrastructure.
198
+ logger.debug("VoiceAgentSystem: Cleaning up ConversationHarness")
199
+ await self.harness.cleanup()
200
+
201
+ logger.debug("VoiceAgentSystem: Cleaning up Bus")
202
+ await self.bus.cleanup()
203
+
204
+ logger.info("VoiceAgentSystem: System cleanup completed")
205
+
206
+ async def wait_for_shutdown(self):
207
+ """Wait until system should shut down (WebSocket disconnect)."""
208
+ await self.bus.shutdown_event.wait()
209
+
210
+ async def send_initial_message(self, message: str):
211
+ """
212
+ Send initial message to user and add as a message to the main node's conversation.
213
+
214
+ Args:
215
+ message (str): Initial greeting message.
216
+ """
217
+ logger.info(f"VoiceAgentSystem: Sending initial message: '{message[:50]}...'")
218
+ await self.bus.broadcast(Message(source=self.authorized_node, event=AgentResponse(content=message)))
219
+
220
+ # Add to main node conversation history.
221
+ if self.main_node and hasattr(self.main_node, "add_event"):
222
+ logger.debug("VoiceAgentSystem: Adding initial message to main node conversation history")
223
+ self.main_node.add_event(AgentResponse(content=message))
224
+
225
+ @property
226
+ def user_bridge(self) -> Bridge:
227
+ """Get the user bridge."""
228
+ if "user" not in self.bridges:
229
+ raise ValueError("User bridge not found. Must call with_speaking_node() first.")
230
+ return self.bridges["user"]