cartesia-line 0.1.0a1__py3-none-any.whl → 0.1.2__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.
- {cartesia_line-0.1.0a1.dist-info → cartesia_line-0.1.2.dist-info}/METADATA +12 -6
- cartesia_line-0.1.2.dist-info/RECORD +32 -0
- line/__init__.py +6 -0
- line/bridge.py +5 -1
- line/evals/__init__.py +10 -0
- line/evals/conversation_runner.py +195 -0
- line/evals/similarity_utils.py +279 -0
- line/evals/turn.py +236 -0
- line/events.py +20 -2
- line/harness.py +19 -4
- line/tools/system_tools.py +140 -1
- line/user_bridge.py +9 -1
- line/utils/str.py +30 -0
- cartesia_line-0.1.0a1.dist-info/RECORD +0 -27
- {cartesia_line-0.1.0a1.dist-info → cartesia_line-0.1.2.dist-info}/WHEEL +0 -0
- {cartesia_line-0.1.0a1.dist-info → cartesia_line-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {cartesia_line-0.1.0a1.dist-info → cartesia_line-0.1.2.dist-info}/top_level.txt +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cartesia-line
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Cartesia Voice Agents SDK
|
|
5
5
|
Author-email: "Cartesia AI, Inc." <support@cartesia.ai>
|
|
6
6
|
License: Apache 2.0
|
|
7
7
|
Project-URL: Repository, https://github.com/cartesia-ai/line
|
|
8
|
-
Project-URL: Documentation, https://docs.cartesia.ai/line
|
|
8
|
+
Project-URL: Documentation, https://docs.cartesia.ai/line/
|
|
9
9
|
Project-URL: Homepage, https://cartesia.ai/agents
|
|
10
10
|
Keywords: voice,agents,ai,cartesia
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -32,9 +32,11 @@ Requires-Dist: uvicorn<1,>=0.35.0
|
|
|
32
32
|
Provides-Extra: dev
|
|
33
33
|
Requires-Dist: pytest; extra == "dev"
|
|
34
34
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-xdist==3.8.0; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-repeat==0.9.4; extra == "dev"
|
|
35
38
|
Requires-Dist: pre-commit; extra == "dev"
|
|
36
39
|
Requires-Dist: ruff==0.12.8; extra == "dev"
|
|
37
|
-
Requires-Dist: pytest-cov; extra == "dev"
|
|
38
40
|
Requires-Dist: google-genai<2,>=1.26.0; extra == "dev"
|
|
39
41
|
Provides-Extra: gemini
|
|
40
42
|
Requires-Dist: google-genai<2,>=1.26.0; python_version >= "3.9" and extra == "gemini"
|
|
@@ -65,9 +67,9 @@ Build intelligent, low-latency voice agents with background reasoning.
|
|
|
65
67
|
|
|
66
68
|
## Quickstart (< 5 minutes)
|
|
67
69
|
|
|
68
|
-
The Line SDK is designed to be used with the Cartesia [Line
|
|
70
|
+
The Line SDK is designed to be used with the Cartesia's voice agent platform [Line](https://cartesia.ai/agents).
|
|
69
71
|
- Create a [Cartesia account](https://play.cartesia.ai).
|
|
70
|
-
- Follow the [quickstart guide](https://docs.cartesia.ai/).
|
|
72
|
+
- Follow the [quickstart guide](https://docs.cartesia.ai/line/start-building/talk-to-your-first-agent).
|
|
71
73
|
|
|
72
74
|
And you'll be able to make your first voice call in a few minutes.
|
|
73
75
|
|
|
@@ -90,5 +92,9 @@ pip install cartesia-line
|
|
|
90
92
|
## Going Deeper
|
|
91
93
|
|
|
92
94
|
- **More examples**: [examples/](examples/) - See all available examples and patterns
|
|
93
|
-
- **
|
|
95
|
+
- **3rd party integrations**: [example_integrations/](example_integrations/) - See example integrations for external services
|
|
96
|
+
|
|
97
|
+
> [!NOTE]
|
|
98
|
+
> While Cartesia approves each example, they are implemented and maintained by our partners.
|
|
99
|
+
- **Full API reference**: [docs.cartesia.ai/line](https://docs.cartesia.ai/line/)
|
|
94
100
|
- **Get help**: [Discord community](https://discord.gg/cartesia)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
cartesia_line-0.1.2.dist-info/licenses/LICENSE,sha256=ElpXuEGJlZ5XPeLe_tLbAmiEVE79EGxdy-aMw3JxE_Y,11347
|
|
2
|
+
line/__init__.py,sha256=3gv8DHGZLy1wDWc7c07UWLIID-TojCvkslgzOjq_HAs,953
|
|
3
|
+
line/bridge.py,sha256=KjaZxEA1QuqtTASej5mYhe6C9OJdpu85b9jcEsBxMvA,14233
|
|
4
|
+
line/bus.py,sha256=AGMwHCNftVeWDzl0o1Jd2iDm51q1FTBEOlJzGZLub1Q,14324
|
|
5
|
+
line/call_request.py,sha256=JfYS4bAicuwjTHMXdK_PYbvSPj1dht44282yZACGUbU,847
|
|
6
|
+
line/events.py,sha256=ZHG1lJuRAACLyqUizqmntBkjAp1bxiEetiIuZBUfDpI,5897
|
|
7
|
+
line/harness.py,sha256=vw6AhsQwODxtnHBW0Q-wTxnqxlUhV8KoZHMJMuTjTyg,9100
|
|
8
|
+
line/harness_types.py,sha256=Lwu1n2fAA5o-b_PjLWDSMu4G-GYEpb3X1CAUy3pvelk,2214
|
|
9
|
+
line/routes.py,sha256=jR_bl96ujx5MYzdVb9YLkcAITCboosM9UM7f4Et9eBc,24446
|
|
10
|
+
line/user_bridge.py,sha256=Hwml4NC_tZbSPPG5rCOdtjBmlGa53rf4L4n5zpF8iUs,7888
|
|
11
|
+
line/voice_agent_app.py,sha256=xlhtc9ZER2v5hOOWHz9W8e9jALlMAz_OvEcJL5QCNsQ,5421
|
|
12
|
+
line/voice_agent_system.py,sha256=8yywrjKdO43s7UkKqIs0jqte27vOF96NYnuGILsJz7k,8002
|
|
13
|
+
line/evals/__init__.py,sha256=zMvJkOMMhd19bcrkH0ZjR4nQ5vjKvPA0ncOFzDe5hv4,226
|
|
14
|
+
line/evals/conversation_runner.py,sha256=lS4IX3pWNjcIEPJsFgfTCoy-CAo986ELYjUfEcC8J7g,7482
|
|
15
|
+
line/evals/similarity_utils.py,sha256=U7LJiCM_dKUC1uOJkhpSS4kD7IZzdOrAisDqsujIIMc,10624
|
|
16
|
+
line/evals/turn.py,sha256=IpEsqnON9JXlzStAcy9YUuE52X4o77V2kLXSJUsW_Tk,8271
|
|
17
|
+
line/nodes/__init__.py,sha256=Rp-9gxMZ4hCTjbm6zaiTTBz6lg9d32UwxJgWTJfhnZU,128
|
|
18
|
+
line/nodes/base.py,sha256=HKfv_j3H-E-cwhtK9La1DUNmYdtbGUNeo-c51TGIQKM,1897
|
|
19
|
+
line/nodes/conversation_context.py,sha256=1ytf4q5GGy6tEjVD_KbKAkKLqosCkwi0kVWGymuLJhw,2138
|
|
20
|
+
line/nodes/reasoning.py,sha256=kyXxk6xJHEA4zwkHzriRtOC6cjk13E-N736qDvR5o-A,8567
|
|
21
|
+
line/tools/__init__.py,sha256=mYzcKIk1G_-EFQD6ugoCdcPjgywK_mC4dXu-ZyAtS7U,203
|
|
22
|
+
line/tools/system_tools.py,sha256=1RkZv_iGAF2LvR3ddoa7Cz4VmC_Xh-BPjmQGKdCu5Fc,8680
|
|
23
|
+
line/tools/tool_types.py,sha256=JJ6mfH9wB-dtaQFHkm8vsJIYsAiW6bIpgAh_nmDn744,1066
|
|
24
|
+
line/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
+
line/utils/aio.py,sha256=l1YgZ8tf1KtOyq6A-6RA_kE-FKGafDfHERFnDx0-PxM,1899
|
|
26
|
+
line/utils/gemini_utils.py,sha256=6YNyU3-I6Kn95j0qMroelO1WpjIPjIwhiub2lX34-kY,5396
|
|
27
|
+
line/utils/openai_utils.py,sha256=I9nIpHTFC98ChWzRmV-enMSIcicRVF9KYjifsWkoPCE,3596
|
|
28
|
+
line/utils/str.py,sha256=b0HCAMdqHh5S8KVulqLwT3x7dj6Tdgaw2WNPAw1gzEY,860
|
|
29
|
+
cartesia_line-0.1.2.dist-info/METADATA,sha256=N5HIRt1IS6Xcr3WoEYH8gq6887LyrjtRNmeChZYzzR4,4402
|
|
30
|
+
cartesia_line-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
31
|
+
cartesia_line-0.1.2.dist-info/top_level.txt,sha256=xztzr4hR6ekbxrTcEufazgor-5McHQuLNu82cxn1jNE,5
|
|
32
|
+
cartesia_line-0.1.2.dist-info/RECORD,,
|
line/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from line.bridge import Bridge
|
|
4
4
|
from line.bus import Bus, Message
|
|
5
5
|
from line.call_request import CallRequest, PreCallResult
|
|
6
|
+
from line.evals import AgentTurn, ConversationRunner, Turn, UserTurn
|
|
6
7
|
from line.nodes.conversation_context import ConversationContext
|
|
7
8
|
|
|
8
9
|
# Reasoning components
|
|
@@ -26,4 +27,9 @@ __all__ = [
|
|
|
26
27
|
"VoiceAgentApp",
|
|
27
28
|
"VoiceAgentSystem",
|
|
28
29
|
"register_observability_event",
|
|
30
|
+
"AgentTurn",
|
|
31
|
+
"ConversationRunner",
|
|
32
|
+
"Turn",
|
|
33
|
+
"UserTurn",
|
|
34
|
+
"SimilarityUtils",
|
|
29
35
|
]
|
line/bridge.py
CHANGED
|
@@ -13,7 +13,11 @@ from typing import TYPE_CHECKING, Any, Callable, List, Optional, Type, TypeVar,
|
|
|
13
13
|
from loguru import logger
|
|
14
14
|
|
|
15
15
|
from line.bus import Bus, Message
|
|
16
|
-
from line.events import
|
|
16
|
+
from line.events import (
|
|
17
|
+
EventInstance,
|
|
18
|
+
EventsRegistry,
|
|
19
|
+
EventTypeOrAlias,
|
|
20
|
+
)
|
|
17
21
|
from line.routes import RouteBuilder, RouteHandler
|
|
18
22
|
|
|
19
23
|
if TYPE_CHECKING:
|
line/evals/__init__.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ConversationRunner - A testing wrapper around ReasoningNode for conversation flow validation.
|
|
3
|
+
|
|
4
|
+
This class allows testing conversation flows by providing expected conversation traces
|
|
5
|
+
and validating that the ReasoningNode produces similar responses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from line.evals.similarity_utils import is_similar_str
|
|
11
|
+
from line.evals.turn import Turn
|
|
12
|
+
from line.events import EventInstance
|
|
13
|
+
from line.nodes.conversation_context import ConversationContext
|
|
14
|
+
from line.nodes.reasoning import ReasoningNode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConversationRunner:
|
|
18
|
+
"""
|
|
19
|
+
A testing wrapper for ReasoningNode that validates conversation flows.
|
|
20
|
+
|
|
21
|
+
This class takes an expected conversation trace and validates that a ReasoningNode
|
|
22
|
+
produces similar responses when given the same user inputs.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
reasoning_node: ReasoningNode,
|
|
28
|
+
expected_conversation: List[Turn],
|
|
29
|
+
initial_agent_message: Optional[str] = None,
|
|
30
|
+
test_note: Optional[str] = None,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Initialize the test conversation.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
reasoning_node: The ReasoningNode to test
|
|
37
|
+
expected_conversation: List of Turn objects representing the expected conversation flow,
|
|
38
|
+
alternating between user and agent turns
|
|
39
|
+
initial_agent_message: Optional initial message from agent to verify against first AgentTurn
|
|
40
|
+
"""
|
|
41
|
+
self.reasoning_node = reasoning_node
|
|
42
|
+
self.expected_conversation = expected_conversation
|
|
43
|
+
self.initial_agent_message = initial_agent_message
|
|
44
|
+
self.test_note = test_note
|
|
45
|
+
|
|
46
|
+
def _verify_initial_agent_message(self) -> Optional[List[EventInstance]]:
|
|
47
|
+
"""
|
|
48
|
+
Verify the initial agent message and return its events if it exists.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of EventInstance if conversation starts with agent turn, None otherwise
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
AssertionError: If initial agent message doesn't match expected first AgentTurn
|
|
55
|
+
"""
|
|
56
|
+
if not self.expected_conversation:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
first_turn = self.expected_conversation[0]
|
|
60
|
+
if not first_turn.is_agent:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# If initial_agent_message is provided, verify it matches
|
|
64
|
+
if self.initial_agent_message is None:
|
|
65
|
+
return first_turn.to_events()
|
|
66
|
+
|
|
67
|
+
if first_turn.text == self.initial_agent_message:
|
|
68
|
+
return first_turn.to_events()
|
|
69
|
+
|
|
70
|
+
results = is_similar_str(self.initial_agent_message, first_turn.text)
|
|
71
|
+
if results.is_success:
|
|
72
|
+
return first_turn.to_events()
|
|
73
|
+
|
|
74
|
+
error_str = (
|
|
75
|
+
f"Initial agent message doesn't match expected first AgentTurn.\n"
|
|
76
|
+
f"Provided initial_agent_message: '{self.initial_agent_message}'\n"
|
|
77
|
+
f"Expected first AgentTurn text: '{first_turn.text}'\n"
|
|
78
|
+
f"Similarity error: {results.error}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if self.test_note is not None:
|
|
82
|
+
error_str += f"\nTest notes: {self.test_note}"
|
|
83
|
+
|
|
84
|
+
raise AssertionError(error_str)
|
|
85
|
+
|
|
86
|
+
def _verify_conversation_pattern(self) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Validate that the conversation follows proper alternating user-assistant pattern.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If the conversation pattern is invalid
|
|
92
|
+
"""
|
|
93
|
+
if not self.expected_conversation:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# Ensure conversation ends with agent turn
|
|
97
|
+
last_turn = self.expected_conversation[-1]
|
|
98
|
+
if not last_turn.is_agent:
|
|
99
|
+
error_str = "Conversation must end with agent turn."
|
|
100
|
+
if self.test_note is not None:
|
|
101
|
+
error_str += f"\nTest notes: {self.test_note}"
|
|
102
|
+
raise ValueError(error_str)
|
|
103
|
+
|
|
104
|
+
# Validate alternating pattern
|
|
105
|
+
for i in range(1, len(self.expected_conversation)):
|
|
106
|
+
current_turn = self.expected_conversation[i]
|
|
107
|
+
previous_turn = self.expected_conversation[i - 1]
|
|
108
|
+
|
|
109
|
+
same_type = (current_turn.is_user and previous_turn.is_user) or (
|
|
110
|
+
current_turn.is_agent and previous_turn.is_agent
|
|
111
|
+
)
|
|
112
|
+
if same_type:
|
|
113
|
+
error_str = (
|
|
114
|
+
f"Invalid conversation pattern at position {i}: "
|
|
115
|
+
f"Two consecutive '{current_turn.role}' turns. "
|
|
116
|
+
f"Expected alternating user-assistant pattern."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if self.test_note is not None:
|
|
120
|
+
error_str += f"\nTest notes: {self.test_note}"
|
|
121
|
+
raise ValueError(error_str)
|
|
122
|
+
|
|
123
|
+
async def run(self) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Run the conversation test, validating each agent response against expected.
|
|
126
|
+
|
|
127
|
+
This method processes the expected conversation turn by turn:
|
|
128
|
+
1. Process user turns by adding them to conversation history
|
|
129
|
+
2. For each user turn, get the expected agent response
|
|
130
|
+
3. Build ConversationContext and call process_context() on ReasoningNode
|
|
131
|
+
4. Convert actual response to Turn and validate similarity
|
|
132
|
+
5. Continue with next turn
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If conversation pattern is invalid (non-alternating user-assistant turns)
|
|
136
|
+
AssertionError: If any agent response doesn't match expected
|
|
137
|
+
"""
|
|
138
|
+
# Validate conversation pattern first
|
|
139
|
+
self._verify_conversation_pattern()
|
|
140
|
+
|
|
141
|
+
# Track conversation history
|
|
142
|
+
conversation_history: List[EventInstance] = []
|
|
143
|
+
|
|
144
|
+
# Handle initial agent message
|
|
145
|
+
initial_events = self._verify_initial_agent_message()
|
|
146
|
+
i = 0
|
|
147
|
+
if initial_events is not None:
|
|
148
|
+
# Add the first agent turn to conversation history and skip it
|
|
149
|
+
conversation_history.extend(initial_events)
|
|
150
|
+
i = 1
|
|
151
|
+
|
|
152
|
+
while i < len(self.expected_conversation):
|
|
153
|
+
user_turn = self.expected_conversation[i]
|
|
154
|
+
|
|
155
|
+
# Add user turn events to history
|
|
156
|
+
user_events = user_turn.to_events()
|
|
157
|
+
conversation_history.extend(user_events)
|
|
158
|
+
i += 1
|
|
159
|
+
|
|
160
|
+
# Get expected agent response from following turn
|
|
161
|
+
expected_agent_turn = self.expected_conversation[i]
|
|
162
|
+
|
|
163
|
+
# Build conversation context from history
|
|
164
|
+
ctx = ConversationContext(
|
|
165
|
+
events=conversation_history.copy(),
|
|
166
|
+
system_prompt=self.reasoning_node.system_prompt,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Get actual response from reasoning node
|
|
170
|
+
actual_events = []
|
|
171
|
+
async for event in self.reasoning_node.process_context(ctx):
|
|
172
|
+
actual_events.append(event)
|
|
173
|
+
|
|
174
|
+
# Convert actual events to Turn
|
|
175
|
+
actual_turn = Turn.from_events(actual_events)
|
|
176
|
+
|
|
177
|
+
# Validate similarity
|
|
178
|
+
similarity_error = expected_agent_turn.is_similar(actual_turn)
|
|
179
|
+
if similarity_error is not None:
|
|
180
|
+
error_str = (
|
|
181
|
+
f"Agent turn doesn't match expected.\n"
|
|
182
|
+
f" User message: {user_turn.text}\n"
|
|
183
|
+
f" Expected: {expected_agent_turn}\n"
|
|
184
|
+
f" Actual: {actual_turn}\n"
|
|
185
|
+
f" Reason: {similarity_error}\n"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if self.test_note is not None:
|
|
189
|
+
error_str += f"\nTest notes: {self.test_note}"
|
|
190
|
+
|
|
191
|
+
raise AssertionError(error_str)
|
|
192
|
+
|
|
193
|
+
# Add actual agent turn events to history for next iteration
|
|
194
|
+
conversation_history.extend(actual_events)
|
|
195
|
+
i += 1
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Similarity checking utilities for conversation evaluation.
|
|
3
|
+
|
|
4
|
+
This module provides functions for comparing strings and dictionaries with semantic
|
|
5
|
+
similarity checking using AI models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Dict, List, Optional, Union # noqa: F401
|
|
10
|
+
|
|
11
|
+
from google.genai import Client
|
|
12
|
+
from google.genai.types import GenerateContentConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SimilarityResult:
|
|
17
|
+
is_success: Optional[bool] # None = if not applicable
|
|
18
|
+
error: Optional[str] # Error message if not successful
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_statement_pattern(s: str) -> bool:
|
|
22
|
+
"""Check if string is a statement pattern like <mentions something>."""
|
|
23
|
+
return s.strip().startswith("<") and s.strip().endswith(">")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_statement(s: str) -> str:
|
|
27
|
+
"""Extract statement content from pattern by removing < and >."""
|
|
28
|
+
return s.strip()[1:-1]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_string_statement(statement: str, actual_text: str) -> SimilarityResult:
|
|
32
|
+
"""Check if actual text matches a statement pattern.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
statement: The statement description (without < >)
|
|
36
|
+
actual_text: The actual text to check against the statement
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
None if text matches statement, error message string if not
|
|
40
|
+
"""
|
|
41
|
+
client = Client()
|
|
42
|
+
|
|
43
|
+
prompt = f"""
|
|
44
|
+
Check if the following text matches this statement/requirement:
|
|
45
|
+
|
|
46
|
+
Statement: "{statement}"
|
|
47
|
+
Text: "{actual_text}"
|
|
48
|
+
|
|
49
|
+
Instructions:
|
|
50
|
+
- Respond with "YES" if the text matches the statement, or "NO: [reason]" if it doesn't.
|
|
51
|
+
- The text should contain or express the concept described in the statement.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
- Statement: "mentions SOC-2 compliance" vs Text: "Our security audit passed SOC-2 requirements" → YES
|
|
55
|
+
- Statement: "mentions SOC-2 compliance" vs Text: "We follow security best practices" → NO:
|
|
56
|
+
Doesn't mention SOC-2
|
|
57
|
+
- Statement: "asks for user name" vs Text: "What's your name?" → YES
|
|
58
|
+
- Statement: "asks for user name" vs Text: "How old are you?" → NO: Asks for age, not name
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
config = GenerateContentConfig(
|
|
62
|
+
temperature=0.1,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
response = client.models.generate_content(model="gemini-2.5-flash-lite", contents=prompt, config=config)
|
|
66
|
+
response_text = response.text.strip() if response.text else ""
|
|
67
|
+
|
|
68
|
+
if response_text.upper().startswith("YES"):
|
|
69
|
+
return SimilarityResult(is_success=True, error=None)
|
|
70
|
+
elif response_text.upper().startswith("NO"):
|
|
71
|
+
reason = response_text[3:].strip().lstrip(":").strip()
|
|
72
|
+
return SimilarityResult(is_success=False, error=reason)
|
|
73
|
+
else:
|
|
74
|
+
return SimilarityResult(
|
|
75
|
+
is_success=False,
|
|
76
|
+
error=f"Unexpected response format from statement check: {response_text}",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_similar_str(a: str, b: str) -> SimilarityResult:
|
|
81
|
+
"""Check if two strings have the same meaning using Gemini with special rule support.
|
|
82
|
+
|
|
83
|
+
Special Rules:
|
|
84
|
+
- "*" wildcard: Matches any string content (either a or b can be "*")
|
|
85
|
+
- Statement patterns: Strings like "<mentions SOC-2 compliance>" match text containing that concept
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
a: First string to compare
|
|
89
|
+
b: Second string to compare
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
None if strings are similar, error message string if not
|
|
93
|
+
"""
|
|
94
|
+
# * means any string is allowed
|
|
95
|
+
if a == "*" or b == "*":
|
|
96
|
+
return SimilarityResult(is_success=True, error=None)
|
|
97
|
+
|
|
98
|
+
# Handle statement patterns
|
|
99
|
+
result = is_similar_via_statement_pattern(a, b)
|
|
100
|
+
if result.is_success is not None:
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
# Handle single text comparision
|
|
104
|
+
return is_similar_via_single_text_comparison(a, b)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_similar_via_statement_pattern(a: str, b: str) -> SimilarityResult:
|
|
108
|
+
a_is_statement = is_statement_pattern(a)
|
|
109
|
+
b_is_statement = is_statement_pattern(b)
|
|
110
|
+
|
|
111
|
+
if a_is_statement or b_is_statement:
|
|
112
|
+
# At least one is a statement pattern
|
|
113
|
+
if a_is_statement and b_is_statement:
|
|
114
|
+
# Both are statement patterns - compare the statements themselves
|
|
115
|
+
statement_a = extract_statement(a)
|
|
116
|
+
statement_b = extract_statement(b)
|
|
117
|
+
return is_similar_str(statement_a, statement_b) # Recursive call without < >
|
|
118
|
+
|
|
119
|
+
# One is a statement, one is actual text
|
|
120
|
+
statement = extract_statement(a) if a_is_statement else extract_statement(b)
|
|
121
|
+
actual_text = b if a_is_statement else a
|
|
122
|
+
|
|
123
|
+
return check_string_statement(statement, actual_text)
|
|
124
|
+
|
|
125
|
+
return SimilarityResult(is_success=None, error=None)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_similar_via_single_text_comparison(a: str, b: str) -> SimilarityResult:
|
|
129
|
+
# First check if strings are equal after basic normalization
|
|
130
|
+
if a.lower().strip() == b.lower().strip():
|
|
131
|
+
return SimilarityResult(is_success=True, error=None)
|
|
132
|
+
|
|
133
|
+
client = Client()
|
|
134
|
+
|
|
135
|
+
prompt = f"""
|
|
136
|
+
Compare these two strings and determine if they have the same or very similar meaning:
|
|
137
|
+
|
|
138
|
+
String A: "{a}"
|
|
139
|
+
String B: "{b}"
|
|
140
|
+
|
|
141
|
+
Rules:
|
|
142
|
+
- Respond with "YES" if they have the same meaning, or "NO: [reason]" if they don't.
|
|
143
|
+
- Consider paraphrasing, synonyms, and different ways of expressing the same concept.
|
|
144
|
+
- Ignore filler prefixes like "Now", "Okay", "Got it", "Thank you", "Finally", "Sounds good", etc.
|
|
145
|
+
- Affirmative phrases like "yes", "that is correct" or "correct" are similar
|
|
146
|
+
- For alphanumeric matching, you may allow mismatches on spacing
|
|
147
|
+
- For alphanumeric matching, you may allow matching when spelled out (e.g. 1 is equivalent to "one", 2 is equivalent to "two", etc.)
|
|
148
|
+
- For alphanumeric matching, you may allow semantic matching between spelled out numbers with spaces or concatenated string of digits
|
|
149
|
+
|
|
150
|
+
Examples:
|
|
151
|
+
- "What's your name?" vs "Can you tell me your name?" → YES
|
|
152
|
+
- "What's your name?" vs "What's your age?" → NO: Different information being requested
|
|
153
|
+
- "You are verified" vs "Your identity is confirmed" → YES
|
|
154
|
+
- "Now, what's your Name?" vs "Thank you, what's your name?" → YES
|
|
155
|
+
- "Hello" vs "Goodbye" → NO: Opposite greetings with different meanings
|
|
156
|
+
- "one two three four" versus "1234" → YES
|
|
157
|
+
""" # noqa: E501
|
|
158
|
+
|
|
159
|
+
config = GenerateContentConfig(
|
|
160
|
+
temperature=0.1, # Low temperature for consistent results
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
response = client.models.generate_content(model="gemini-2.5-flash-lite", contents=prompt, config=config)
|
|
164
|
+
|
|
165
|
+
response_text = response.text.strip() if response.text else ""
|
|
166
|
+
|
|
167
|
+
if response_text.upper().startswith("YES"):
|
|
168
|
+
return SimilarityResult(is_success=True, error=None)
|
|
169
|
+
elif response_text.upper().startswith("NO"):
|
|
170
|
+
# Extract and return reason
|
|
171
|
+
reason = response_text[3:].strip().lstrip(":").strip()
|
|
172
|
+
return SimilarityResult(is_success=False, error=reason)
|
|
173
|
+
else:
|
|
174
|
+
# Fallback in case of unexpected response format
|
|
175
|
+
return SimilarityResult(
|
|
176
|
+
is_success=False,
|
|
177
|
+
error=f"Unexpected response format from similarity check: {response_text}\n"
|
|
178
|
+
f'String A: "{a}"\nString B: "{b}"',
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def is_similar_text(a: Union[List[str], str], b: Union[List[str], str]) -> SimilarityResult:
|
|
183
|
+
"""Given two texts that are lists, check that at least one element from a is similar to one element from b.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
a: First list of strings to compare
|
|
187
|
+
b: Second list of strings to compare
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
SimilarityResult indicating if the lists are similar
|
|
191
|
+
""" # noqa: E501
|
|
192
|
+
a = [a] if isinstance(a, str) else a
|
|
193
|
+
b = [b] if isinstance(b, str) else b
|
|
194
|
+
|
|
195
|
+
if not a and not b:
|
|
196
|
+
raise RuntimeError("Both lists are empty")
|
|
197
|
+
if not a or not b:
|
|
198
|
+
return SimilarityResult(is_success=False, error=f"One list is empty: a={a}, b={b}")
|
|
199
|
+
|
|
200
|
+
# Check if any element from 'a' is similar to any element from 'b'
|
|
201
|
+
for a_item in a:
|
|
202
|
+
for b_item in b:
|
|
203
|
+
result = is_similar_str(a_item, b_item)
|
|
204
|
+
if result.is_success:
|
|
205
|
+
return SimilarityResult(is_success=True, error=None)
|
|
206
|
+
|
|
207
|
+
if len(a) == 1 and len(b) == 1:
|
|
208
|
+
return SimilarityResult(is_success=False, error=f"{a} != {b}")
|
|
209
|
+
else:
|
|
210
|
+
return SimilarityResult(
|
|
211
|
+
is_success=False, error=f"No similar elements found the following two lists: a={a}, b={b}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def is_similar_dict(actual: Dict, expected: Dict) -> SimilarityResult:
|
|
216
|
+
"""Recursively check if two dictionaries are similar.
|
|
217
|
+
|
|
218
|
+
Uses string similarity checking for string values and recursive comparison for nested dicts.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
actual: The actual dictionary
|
|
222
|
+
expected: The expected dictionary
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
None if dictionaries are similar, error message string if not
|
|
226
|
+
"""
|
|
227
|
+
# Check if keys match
|
|
228
|
+
actual_keys = set(actual.keys())
|
|
229
|
+
expected_keys = set(expected.keys())
|
|
230
|
+
|
|
231
|
+
if actual_keys != expected_keys:
|
|
232
|
+
missing_keys = expected_keys - actual_keys
|
|
233
|
+
extra_keys = actual_keys - expected_keys
|
|
234
|
+
error_parts = []
|
|
235
|
+
if missing_keys:
|
|
236
|
+
error_parts.append(f"missing keys: {list(missing_keys)}")
|
|
237
|
+
if extra_keys:
|
|
238
|
+
error_parts.append(f"extra keys: {list(extra_keys)}")
|
|
239
|
+
return SimilarityResult(
|
|
240
|
+
is_success=False,
|
|
241
|
+
error=f"Key mismatch - {', '.join(error_parts)}",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Check each key-value pair
|
|
245
|
+
for key in expected_keys:
|
|
246
|
+
actual_value = actual[key]
|
|
247
|
+
expected_value = expected[key]
|
|
248
|
+
|
|
249
|
+
# Skip validation if expected value is None
|
|
250
|
+
if expected_value is None:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Handle string values with similarity checking
|
|
254
|
+
if isinstance(expected_value, str) and isinstance(actual_value, str):
|
|
255
|
+
result = is_similar_str(actual_value, expected_value)
|
|
256
|
+
if result.is_success is False:
|
|
257
|
+
return SimilarityResult(
|
|
258
|
+
is_success=False,
|
|
259
|
+
error=f"String value mismatch for key '{key}': {result.error}",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Handle nested dictionaries
|
|
263
|
+
elif isinstance(expected_value, dict) and isinstance(actual_value, dict):
|
|
264
|
+
error = is_similar_dict(actual_value, expected_value)
|
|
265
|
+
if error.is_success is False:
|
|
266
|
+
return SimilarityResult(
|
|
267
|
+
is_success=False,
|
|
268
|
+
error=f"Nested dict mismatch for key '{key}': {error}",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Handle other types with exact comparison
|
|
272
|
+
else:
|
|
273
|
+
if actual_value != expected_value:
|
|
274
|
+
return SimilarityResult(
|
|
275
|
+
is_success=False,
|
|
276
|
+
error=f"Value mismatch for key '{key}': expected {expected_value}, got {actual_value}",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return SimilarityResult(is_success=True, error=None)
|
line/evals/turn.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Turn-based conversation representation for evaluation.
|
|
3
|
+
|
|
4
|
+
This module provides Turn classes that represent conversation turns with automatic
|
|
5
|
+
conversion to/from Event instances for use with ReasoningNode testing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from line.evals.similarity_utils import is_similar_dict, is_similar_text
|
|
14
|
+
from line.events import (
|
|
15
|
+
AgentResponse,
|
|
16
|
+
DTMFOutputEvent,
|
|
17
|
+
EndCall,
|
|
18
|
+
EventInstance,
|
|
19
|
+
ToolResult,
|
|
20
|
+
TransferCall,
|
|
21
|
+
UserTranscriptionReceived,
|
|
22
|
+
)
|
|
23
|
+
from line.events import (
|
|
24
|
+
ToolCall as EventToolCall,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolCall(BaseModel):
|
|
29
|
+
"""Tool call representation within a Turn."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
arguments: Dict[str, Any] = Field(default_factory=dict)
|
|
33
|
+
result: Any = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Turn(BaseModel):
|
|
37
|
+
"""Base class for conversation turns with event conversion capabilities."""
|
|
38
|
+
|
|
39
|
+
role: Literal["user", "assistant"]
|
|
40
|
+
text: Union[List[str], str] = ""
|
|
41
|
+
tool_calls: List[ToolCall] = Field(default_factory=list)
|
|
42
|
+
telephony_events: list[Union[DTMFOutputEvent, TransferCall, EndCall]] = Field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_user(self) -> bool:
|
|
46
|
+
"""Check if this is a user turn."""
|
|
47
|
+
return self.role == "user"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_agent(self) -> bool:
|
|
51
|
+
"""Check if this is an agent turn."""
|
|
52
|
+
return self.role == "assistant"
|
|
53
|
+
|
|
54
|
+
def to_events(self) -> List[EventInstance]:
|
|
55
|
+
"""Convert this turn to a list of Event instances."""
|
|
56
|
+
events = []
|
|
57
|
+
|
|
58
|
+
if self.role == "user":
|
|
59
|
+
if isinstance(self.text, str):
|
|
60
|
+
events.append(UserTranscriptionReceived(content=self.text))
|
|
61
|
+
return events
|
|
62
|
+
|
|
63
|
+
# Otherwise, it must be a list
|
|
64
|
+
if len(self.text) != 1:
|
|
65
|
+
raise RuntimeError("Must include exactly one text element for user turn. {len(self.text)=}")
|
|
66
|
+
if self.text:
|
|
67
|
+
# Join all text elements with a space for user transcription
|
|
68
|
+
events.append(UserTranscriptionReceived(content=self.text[0]))
|
|
69
|
+
elif self.role == "assistant":
|
|
70
|
+
# Add tool calls first
|
|
71
|
+
for tool_call in self.tool_calls:
|
|
72
|
+
events.append(EventToolCall(tool_name=tool_call.name, tool_args=tool_call.arguments))
|
|
73
|
+
if tool_call.result is not None:
|
|
74
|
+
events.append(
|
|
75
|
+
ToolResult(
|
|
76
|
+
tool_name=tool_call.name,
|
|
77
|
+
tool_args=tool_call.arguments,
|
|
78
|
+
result=tool_call.result,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Add text response
|
|
83
|
+
if self.text:
|
|
84
|
+
if isinstance(self.text, str):
|
|
85
|
+
events.append(AgentResponse(content=self.text))
|
|
86
|
+
elif isinstance(self.text, list):
|
|
87
|
+
events.append(AgentResponse(content=self.text[0]))
|
|
88
|
+
else:
|
|
89
|
+
raise RuntimeError(f"Unexpected text type: {type(self.text)=}")
|
|
90
|
+
|
|
91
|
+
return events
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_events(cls, events: List[EventInstance]) -> "Turn":
|
|
95
|
+
"""Create a Turn from a list of Event instances."""
|
|
96
|
+
text = ""
|
|
97
|
+
tool_calls = []
|
|
98
|
+
role = "assistant" # Default to assistant
|
|
99
|
+
|
|
100
|
+
# Track tool calls and their results
|
|
101
|
+
tool_call_map = {}
|
|
102
|
+
telephony_events = []
|
|
103
|
+
|
|
104
|
+
for event in events:
|
|
105
|
+
if isinstance(event, UserTranscriptionReceived):
|
|
106
|
+
role = "user"
|
|
107
|
+
text += event.content
|
|
108
|
+
elif isinstance(event, AgentResponse):
|
|
109
|
+
role = "assistant"
|
|
110
|
+
text += event.content
|
|
111
|
+
elif isinstance(event, EventToolCall):
|
|
112
|
+
role = "assistant"
|
|
113
|
+
tool_call_map[event.tool_name] = ToolCall(name=event.tool_name, arguments=event.tool_args)
|
|
114
|
+
elif isinstance(event, ToolResult):
|
|
115
|
+
role = "assistant"
|
|
116
|
+
if event.tool_name in tool_call_map:
|
|
117
|
+
tool_call_map[event.tool_name].result = event.result
|
|
118
|
+
else:
|
|
119
|
+
# Create tool call if we only have the result
|
|
120
|
+
tool_call_map[event.tool_name] = ToolCall(
|
|
121
|
+
name=event.tool_name,
|
|
122
|
+
arguments=event.tool_args,
|
|
123
|
+
result=event.result,
|
|
124
|
+
)
|
|
125
|
+
elif (
|
|
126
|
+
isinstance(event, DTMFOutputEvent)
|
|
127
|
+
or isinstance(event, TransferCall)
|
|
128
|
+
or isinstance(event, EndCall)
|
|
129
|
+
):
|
|
130
|
+
role = "assistant"
|
|
131
|
+
telephony_events.append(event)
|
|
132
|
+
|
|
133
|
+
tool_calls = list(tool_call_map.values())
|
|
134
|
+
text = text.strip()
|
|
135
|
+
|
|
136
|
+
return cls(role=role, text=text, tool_calls=tool_calls, telephony_events=telephony_events)
|
|
137
|
+
|
|
138
|
+
def is_similar(self, other: "Turn") -> Optional[str]:
|
|
139
|
+
"""Check if this turn is similar to another turn.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
None if turns are similar, error description string if not
|
|
143
|
+
"""
|
|
144
|
+
# Check role matches
|
|
145
|
+
if self.role != other.role:
|
|
146
|
+
return f"Role mismatch: expected '{other.role}', got '{self.role}'"
|
|
147
|
+
|
|
148
|
+
# Check text similarity
|
|
149
|
+
if self.text or other.text:
|
|
150
|
+
results = is_similar_text(self.text, other.text)
|
|
151
|
+
if results.is_success is False:
|
|
152
|
+
return f"Text mismatch: {results.error}"
|
|
153
|
+
|
|
154
|
+
# Check tool calls match
|
|
155
|
+
if len(self.tool_calls) != len(other.tool_calls):
|
|
156
|
+
return f"Tool call count mismatch: expected {len(other.tool_calls)}, got {len(self.tool_calls)}"
|
|
157
|
+
|
|
158
|
+
# Sort tool calls by name for comparison
|
|
159
|
+
self_tools = sorted(self.tool_calls, key=lambda x: x.name)
|
|
160
|
+
other_tools = sorted(other.tool_calls, key=lambda x: x.name)
|
|
161
|
+
|
|
162
|
+
for self_tool, other_tool in zip(self_tools, other_tools):
|
|
163
|
+
if self_tool.name != other_tool.name:
|
|
164
|
+
return f"Tool name mismatch: expected '{other_tool.name}', got '{self_tool.name}'"
|
|
165
|
+
|
|
166
|
+
# Check arguments similarity
|
|
167
|
+
if self_tool.arguments or other_tool.arguments:
|
|
168
|
+
results = is_similar_dict(self_tool.arguments, other_tool.arguments)
|
|
169
|
+
if results.is_success is False:
|
|
170
|
+
return f"Tool '{self_tool.name}' arguments mismatch: {results.error}"
|
|
171
|
+
|
|
172
|
+
# Check result similarity
|
|
173
|
+
if self_tool.result != other_tool.result:
|
|
174
|
+
return (
|
|
175
|
+
f"Tool '{self_tool.name}' result mismatch: "
|
|
176
|
+
f"expected {other_tool.result}, got {self_tool.result}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if self.telephony_events != other.telephony_events:
|
|
180
|
+
return f"telephony_events mismatch: expected {other.telephony_events} to match {self.telephony_events}" # noqa: E501
|
|
181
|
+
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class UserTurn(Turn):
|
|
186
|
+
"""User conversation turn."""
|
|
187
|
+
|
|
188
|
+
role: Literal["user"] = "user"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class AgentTurn(Turn):
|
|
192
|
+
"""Agent conversation turn."""
|
|
193
|
+
|
|
194
|
+
role: Literal["assistant"] = "assistant"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def make_turn(data: Dict[str, Any]) -> Union[UserTurn, AgentTurn]:
|
|
198
|
+
"""Create a UserTurn or AgentTurn from dictionary data.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
data: Dictionary containing turn data with 'role' field and other turn properties
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
UserTurn or AgentTurn instance based on the role
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ValueError: If role is not 'user' or 'assistant'
|
|
208
|
+
"""
|
|
209
|
+
role = data.get("role")
|
|
210
|
+
|
|
211
|
+
if role == "user":
|
|
212
|
+
return UserTurn(**data)
|
|
213
|
+
elif role == "assistant":
|
|
214
|
+
return AgentTurn(**data)
|
|
215
|
+
else:
|
|
216
|
+
raise ValueError(f"Invalid role '{role}'. Must be 'user' or 'assistant'")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def load_conversation_json(file_path: str) -> List[Union[UserTurn, AgentTurn]]:
|
|
220
|
+
"""Load a conversation from a JSON file.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
file_path: Path to JSON file containing conversation data
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List of Turn instances (UserTurn or AgentTurn)
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
FileNotFoundError: If the file doesn't exist
|
|
230
|
+
json.JSONDecodeError: If the file contains invalid JSON
|
|
231
|
+
ValueError: If any turn has an invalid role
|
|
232
|
+
"""
|
|
233
|
+
with open(file_path, "r") as f:
|
|
234
|
+
data = json.load(f)
|
|
235
|
+
|
|
236
|
+
return [make_turn(turn_data) for turn_data in data]
|
line/events.py
CHANGED
|
@@ -38,6 +38,9 @@ __all__ = [
|
|
|
38
38
|
"AgentSpeechSent",
|
|
39
39
|
"UserUnknownInputReceived",
|
|
40
40
|
"LogMetric",
|
|
41
|
+
"DTMFInputEvent",
|
|
42
|
+
"DTMFOutputEvent",
|
|
43
|
+
"DTMFStoppedEvent",
|
|
41
44
|
]
|
|
42
45
|
|
|
43
46
|
|
|
@@ -131,8 +134,7 @@ class AgentError(BaseModel):
|
|
|
131
134
|
class TransferCall(BaseModel):
|
|
132
135
|
"""Transfer call to destination."""
|
|
133
136
|
|
|
134
|
-
|
|
135
|
-
reason: Optional[str] = None
|
|
137
|
+
target_phone_number: str
|
|
136
138
|
|
|
137
139
|
|
|
138
140
|
class AgentHandoff(BaseModel):
|
|
@@ -183,6 +185,22 @@ class LogMetric(BaseModel):
|
|
|
183
185
|
value: Any
|
|
184
186
|
|
|
185
187
|
|
|
188
|
+
class DTMFInputEvent(BaseModel):
|
|
189
|
+
"""DTMF event for tracking DTMF input."""
|
|
190
|
+
|
|
191
|
+
button: str
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class DTMFOutputEvent(BaseModel):
|
|
195
|
+
"""DTMF event for tracking DTMF input."""
|
|
196
|
+
|
|
197
|
+
button: str
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class DTMFStoppedEvent(BaseModel):
|
|
201
|
+
"""DTMF stopped event for tracking DTMF input."""
|
|
202
|
+
|
|
203
|
+
|
|
186
204
|
class _EventsRegistry:
|
|
187
205
|
"""A singleton registry of all events.
|
|
188
206
|
|
line/harness.py
CHANGED
|
@@ -16,6 +16,7 @@ from line.events import (
|
|
|
16
16
|
AgentSpeechSent,
|
|
17
17
|
AgentStartedSpeaking,
|
|
18
18
|
AgentStoppedSpeaking,
|
|
19
|
+
DTMFInputEvent,
|
|
19
20
|
UserStartedSpeaking,
|
|
20
21
|
UserStoppedSpeaking,
|
|
21
22
|
UserTranscriptionReceived,
|
|
@@ -24,6 +25,8 @@ from line.events import (
|
|
|
24
25
|
from line.harness_types import (
|
|
25
26
|
AgentSpeechInput,
|
|
26
27
|
AgentStateInput,
|
|
28
|
+
DTMFInput,
|
|
29
|
+
DTMFOutput,
|
|
27
30
|
EndCallOutput,
|
|
28
31
|
ErrorOutput,
|
|
29
32
|
InputMessage,
|
|
@@ -140,15 +143,15 @@ class ConversationHarness:
|
|
|
140
143
|
await self._send(EndCallOutput())
|
|
141
144
|
logger.info("End call message sent")
|
|
142
145
|
|
|
143
|
-
async def transfer_call(self,
|
|
146
|
+
async def transfer_call(self, target_phone_number: str = ""):
|
|
144
147
|
"""
|
|
145
148
|
Send transfer_call message
|
|
146
149
|
|
|
147
150
|
Args:
|
|
148
|
-
|
|
151
|
+
target_phone_number: Optional target phone number for call transfer
|
|
149
152
|
"""
|
|
150
|
-
await self._send(TransferOutput(target_phone_number=
|
|
151
|
-
logger.info(f"Transfer call message sent to {
|
|
153
|
+
await self._send(TransferOutput(target_phone_number=target_phone_number))
|
|
154
|
+
logger.info(f"Transfer call message sent to {target_phone_number}")
|
|
152
155
|
self.shutdown_event.set()
|
|
153
156
|
|
|
154
157
|
async def send_message(self, message: str):
|
|
@@ -199,6 +202,15 @@ class ConversationHarness:
|
|
|
199
202
|
logger.debug(f"📈 Logging metric: {name}={value}")
|
|
200
203
|
await self._send(LogMetricOutput(name=name, value=value))
|
|
201
204
|
|
|
205
|
+
async def send_dtmf(self, button: str):
|
|
206
|
+
"""
|
|
207
|
+
Send a DTMF event via WebSocket
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
button: The DTMF button to send
|
|
211
|
+
"""
|
|
212
|
+
await self._send(DTMFOutput(button=button))
|
|
213
|
+
|
|
202
214
|
async def cleanup(self):
|
|
203
215
|
"""
|
|
204
216
|
Clean up resources and stop all tasks
|
|
@@ -249,6 +261,9 @@ class ConversationHarness:
|
|
|
249
261
|
elif isinstance(message, AgentSpeechInput):
|
|
250
262
|
logger.info(f'🗣️ Agent speech sent: "{message.content}"')
|
|
251
263
|
return [AgentSpeechSent(content=message.content)]
|
|
264
|
+
elif isinstance(message, DTMFInput):
|
|
265
|
+
logger.info(f"🔔 DTMF received: {message.button}")
|
|
266
|
+
return [DTMFInputEvent(button=message.button)]
|
|
252
267
|
else:
|
|
253
268
|
# Fallback for unknown types.
|
|
254
269
|
logger.warning(f"Unknown message type: {type(message).__name__} ({message.model_dump_json()})")
|
line/tools/system_tools.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""System tool definitions for Cartesia Voice Agents SDK."""
|
|
2
2
|
|
|
3
|
-
from typing import AsyncGenerator, Dict, Union
|
|
3
|
+
from typing import AsyncGenerator, Dict, List, Optional, Union
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel, Field
|
|
6
6
|
|
|
7
7
|
from line.events import AgentResponse, EndCall
|
|
8
8
|
from line.tools.tool_types import ToolDefinition
|
|
9
|
+
from line.utils.str import is_e164_phone_number
|
|
9
10
|
|
|
10
11
|
try:
|
|
11
12
|
from google.genai import types as gemini_types
|
|
@@ -118,3 +119,141 @@ async def end_call(
|
|
|
118
119
|
|
|
119
120
|
# End the call
|
|
120
121
|
yield EndCall()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DTMFToolCall(ToolDefinition):
|
|
125
|
+
"""Arguments for the dtmf_tool_call tool."""
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def name(cls) -> str:
|
|
129
|
+
return "dtmf_tool_call"
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def description(cls) -> str:
|
|
133
|
+
return (
|
|
134
|
+
"Send a DTMF tone to the user. Use this when you find the "
|
|
135
|
+
"appropriate selection and the voice system asks you to press a button"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def parameters_description(cls) -> str:
|
|
140
|
+
return "The DTMF button to send"
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def to_gemini_tool(cls) -> "gemini_types.Tool":
|
|
144
|
+
"""Convert to Gemini tool format"""
|
|
145
|
+
return gemini_types.Tool(
|
|
146
|
+
function_declarations=[
|
|
147
|
+
gemini_types.FunctionDeclaration(
|
|
148
|
+
name=cls.name(),
|
|
149
|
+
description=cls.description(),
|
|
150
|
+
parameters={
|
|
151
|
+
"type": "object",
|
|
152
|
+
"properties": {
|
|
153
|
+
"button": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"description": cls.parameters_description(),
|
|
156
|
+
"enum": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"],
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
"required": ["button"],
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def to_openai_tool(cls) -> Dict[str, object]:
|
|
167
|
+
"""Convert to OpenAI tool format for Responses API.
|
|
168
|
+
|
|
169
|
+
Note: This returns the format expected by OpenAI's Responses API,
|
|
170
|
+
not the Chat Completions API format.
|
|
171
|
+
"""
|
|
172
|
+
return {
|
|
173
|
+
"type": "function",
|
|
174
|
+
"name": cls.name(),
|
|
175
|
+
"description": cls.description(),
|
|
176
|
+
"parameters": {
|
|
177
|
+
"type": "object",
|
|
178
|
+
"properties": {
|
|
179
|
+
"button": {
|
|
180
|
+
"type": "string",
|
|
181
|
+
"enum": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"],
|
|
182
|
+
"description": cls.parameters_description(),
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
"required": ["button"],
|
|
186
|
+
"additionalProperties": False,
|
|
187
|
+
"strict": True,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TransferToolCall(ToolDefinition): # noqa: F811
|
|
193
|
+
"""Arguments for the transfer_tool_call tool."""
|
|
194
|
+
|
|
195
|
+
def __init__(self, target_phone_numbers: List[str], description: Optional[str] = None):
|
|
196
|
+
for destination in target_phone_numbers:
|
|
197
|
+
if not is_e164_phone_number(destination):
|
|
198
|
+
raise ValueError(f"Invalid destination phone number. {destination=}")
|
|
199
|
+
|
|
200
|
+
self.target_phone_numbers = target_phone_numbers
|
|
201
|
+
self._description = description
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def name(cls) -> str:
|
|
205
|
+
return "transfer_tool"
|
|
206
|
+
|
|
207
|
+
def description(self) -> str:
|
|
208
|
+
return self._description or "Initiates a transfer of the call to the destination phone number."
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def parameters_description(cls) -> str:
|
|
212
|
+
return "The destination phone number to transfer the call to"
|
|
213
|
+
|
|
214
|
+
def to_gemini_tool(self) -> "gemini_types.Tool":
|
|
215
|
+
"""Convert to Gemini tool format"""
|
|
216
|
+
return gemini_types.Tool(
|
|
217
|
+
function_declarations=[
|
|
218
|
+
gemini_types.FunctionDeclaration(
|
|
219
|
+
name=self.name(),
|
|
220
|
+
description=self.description(),
|
|
221
|
+
parameters={
|
|
222
|
+
"type": "object",
|
|
223
|
+
"properties": {
|
|
224
|
+
"target_phone_number": {
|
|
225
|
+
"type": "string",
|
|
226
|
+
"description": self.parameters_description(),
|
|
227
|
+
"enum": self.target_phone_numbers,
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
"required": ["target_phone_number"],
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def to_openai_tool(self) -> Dict[str, object]:
|
|
237
|
+
"""Convert to OpenAI tool format for Responses API.
|
|
238
|
+
|
|
239
|
+
Note: This returns the format expected by OpenAI's Responses API,
|
|
240
|
+
not the Chat Completions API format.
|
|
241
|
+
"""
|
|
242
|
+
return {
|
|
243
|
+
"type": "function",
|
|
244
|
+
"name": self.name(),
|
|
245
|
+
"description": self.description(),
|
|
246
|
+
"parameters": {
|
|
247
|
+
"type": "object",
|
|
248
|
+
"properties": {
|
|
249
|
+
"target_phone_number": {
|
|
250
|
+
"type": "string",
|
|
251
|
+
"enum": self.target_phone_numbers,
|
|
252
|
+
"description": self.parameters_description(),
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
"required": ["target_phone_number"],
|
|
256
|
+
"additionalProperties": False,
|
|
257
|
+
"strict": True,
|
|
258
|
+
},
|
|
259
|
+
}
|
line/user_bridge.py
CHANGED
|
@@ -44,6 +44,7 @@ from line.events import (
|
|
|
44
44
|
AgentError,
|
|
45
45
|
AgentResponse,
|
|
46
46
|
Authorize,
|
|
47
|
+
DTMFOutputEvent,
|
|
47
48
|
EndCall,
|
|
48
49
|
EventType,
|
|
49
50
|
LogMetric,
|
|
@@ -104,13 +105,18 @@ def create_user_bridge(harness: "ConversationHarness", authorized_node: str) ->
|
|
|
104
105
|
async def send_transfer_call(message: Message):
|
|
105
106
|
"""Transfer call to destination."""
|
|
106
107
|
event: TransferCall = message.event
|
|
107
|
-
return await harness.transfer_call(event.
|
|
108
|
+
return await harness.transfer_call(event.target_phone_number)
|
|
108
109
|
|
|
109
110
|
async def send_log_metric(message: Message):
|
|
110
111
|
"""Log metric via harness."""
|
|
111
112
|
event: LogMetric = message.event
|
|
112
113
|
return await harness.log_metric(event.name, event.value)
|
|
113
114
|
|
|
115
|
+
async def send_dtmf(message: Message):
|
|
116
|
+
"""Send DTMF event to harness."""
|
|
117
|
+
event: DTMFOutputEvent = message.event
|
|
118
|
+
return await harness.send_dtmf(event.button)
|
|
119
|
+
|
|
114
120
|
bridge = (
|
|
115
121
|
Bridge(harness)
|
|
116
122
|
.with_input_routing(harness) # Enable WebSocket → bus event routing
|
|
@@ -132,6 +138,8 @@ def create_user_bridge(harness: "ConversationHarness", authorized_node: str) ->
|
|
|
132
138
|
.map(send_transfer_call)
|
|
133
139
|
.on(LogMetric)
|
|
134
140
|
.map(send_log_metric)
|
|
141
|
+
.on(DTMFOutputEvent)
|
|
142
|
+
.map(send_dtmf)
|
|
135
143
|
)
|
|
136
144
|
|
|
137
145
|
# Add authorization handler after creation.
|
line/utils/str.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
def is_e164_phone_number(phone: str) -> bool:
|
|
2
|
+
"""Check if a string is a valid E.164 compliant phone number.
|
|
3
|
+
|
|
4
|
+
E.164 format requirements:
|
|
5
|
+
- Must start with '+'
|
|
6
|
+
- Followed by 5-15 digits
|
|
7
|
+
- No spaces, hyphens, or other characters
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
phone: The phone number string to validate
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
bool: True if the string is E.164 compliant, False otherwise
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Note: 1+4=5 is practically the mininum number of digits. A country can have
|
|
17
|
+
a short national phone number code (len=4) if they are small (e.g. Falkland Islands)
|
|
18
|
+
"""
|
|
19
|
+
# Must start with '+'
|
|
20
|
+
if not phone.startswith("+"):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
# Remove the '+' and check the rest
|
|
24
|
+
digits = phone[1:]
|
|
25
|
+
|
|
26
|
+
# Must be between 1 and 15 digits
|
|
27
|
+
if not digits.isdigit() or len(digits) < 5 or len(digits) > 15:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
return True
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
cartesia_line-0.1.0a1.dist-info/licenses/LICENSE,sha256=ElpXuEGJlZ5XPeLe_tLbAmiEVE79EGxdy-aMw3JxE_Y,11347
|
|
2
|
-
line/__init__.py,sha256=glA0fvCL2xBmgC1cXZbiv6wcXS-yod7ctcL3EnoeEIk,790
|
|
3
|
-
line/bridge.py,sha256=53N5UizGKjrb6iR27OQbTtjgQZ-rNLjEJT0HqQiLa-M,14216
|
|
4
|
-
line/bus.py,sha256=AGMwHCNftVeWDzl0o1Jd2iDm51q1FTBEOlJzGZLub1Q,14324
|
|
5
|
-
line/call_request.py,sha256=JfYS4bAicuwjTHMXdK_PYbvSPj1dht44282yZACGUbU,847
|
|
6
|
-
line/events.py,sha256=d_CtDTGvLyMwFjdO97FtpTwyBO8uSmCzB2CC7hYagiQ,5565
|
|
7
|
-
line/harness.py,sha256=_4TJV3J2Sxdhz5JSuWXlthZLNmhfNlddHANzOLGjtec,8620
|
|
8
|
-
line/harness_types.py,sha256=Lwu1n2fAA5o-b_PjLWDSMu4G-GYEpb3X1CAUy3pvelk,2214
|
|
9
|
-
line/routes.py,sha256=jR_bl96ujx5MYzdVb9YLkcAITCboosM9UM7f4Et9eBc,24446
|
|
10
|
-
line/user_bridge.py,sha256=etnvSMALCDB7JOguW0imd41RSH3sHJ-oBHH2Hr7kSeQ,7620
|
|
11
|
-
line/voice_agent_app.py,sha256=xlhtc9ZER2v5hOOWHz9W8e9jALlMAz_OvEcJL5QCNsQ,5421
|
|
12
|
-
line/voice_agent_system.py,sha256=8yywrjKdO43s7UkKqIs0jqte27vOF96NYnuGILsJz7k,8002
|
|
13
|
-
line/nodes/__init__.py,sha256=Rp-9gxMZ4hCTjbm6zaiTTBz6lg9d32UwxJgWTJfhnZU,128
|
|
14
|
-
line/nodes/base.py,sha256=HKfv_j3H-E-cwhtK9La1DUNmYdtbGUNeo-c51TGIQKM,1897
|
|
15
|
-
line/nodes/conversation_context.py,sha256=1ytf4q5GGy6tEjVD_KbKAkKLqosCkwi0kVWGymuLJhw,2138
|
|
16
|
-
line/nodes/reasoning.py,sha256=kyXxk6xJHEA4zwkHzriRtOC6cjk13E-N736qDvR5o-A,8567
|
|
17
|
-
line/tools/__init__.py,sha256=mYzcKIk1G_-EFQD6ugoCdcPjgywK_mC4dXu-ZyAtS7U,203
|
|
18
|
-
line/tools/system_tools.py,sha256=Z0tDoaAtZ9GziBLSDklhE6NS91OoHrmGkgToP5nZD_o,3765
|
|
19
|
-
line/tools/tool_types.py,sha256=JJ6mfH9wB-dtaQFHkm8vsJIYsAiW6bIpgAh_nmDn744,1066
|
|
20
|
-
line/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
-
line/utils/aio.py,sha256=l1YgZ8tf1KtOyq6A-6RA_kE-FKGafDfHERFnDx0-PxM,1899
|
|
22
|
-
line/utils/gemini_utils.py,sha256=6YNyU3-I6Kn95j0qMroelO1WpjIPjIwhiub2lX34-kY,5396
|
|
23
|
-
line/utils/openai_utils.py,sha256=I9nIpHTFC98ChWzRmV-enMSIcicRVF9KYjifsWkoPCE,3596
|
|
24
|
-
cartesia_line-0.1.0a1.dist-info/METADATA,sha256=_47ursMi_LDtkwhqeKmOjbQFy51r6JQMzL_iptiVf1M,4001
|
|
25
|
-
cartesia_line-0.1.0a1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
26
|
-
cartesia_line-0.1.0a1.dist-info/top_level.txt,sha256=xztzr4hR6ekbxrTcEufazgor-5McHQuLNu82cxn1jNE,5
|
|
27
|
-
cartesia_line-0.1.0a1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|