cartesia-line 0.1.2__py3-none-any.whl → 0.1.4__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.2.dist-info → cartesia_line-0.1.4.dist-info}/METADATA +1 -1
- {cartesia_line-0.1.2.dist-info → cartesia_line-0.1.4.dist-info}/RECORD +9 -7
- line/__init__.py +0 -6
- line/utils/dtmf_lookahead_buffer.py +128 -0
- line/utils/gemini_utils.py +6 -0
- line/utils/log_aiter.py +123 -0
- {cartesia_line-0.1.2.dist-info → cartesia_line-0.1.4.dist-info}/WHEEL +0 -0
- {cartesia_line-0.1.2.dist-info → cartesia_line-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {cartesia_line-0.1.2.dist-info → cartesia_line-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
cartesia_line-0.1.
|
|
2
|
-
line/__init__.py,sha256=
|
|
1
|
+
cartesia_line-0.1.4.dist-info/licenses/LICENSE,sha256=ElpXuEGJlZ5XPeLe_tLbAmiEVE79EGxdy-aMw3JxE_Y,11347
|
|
2
|
+
line/__init__.py,sha256=glA0fvCL2xBmgC1cXZbiv6wcXS-yod7ctcL3EnoeEIk,790
|
|
3
3
|
line/bridge.py,sha256=KjaZxEA1QuqtTASej5mYhe6C9OJdpu85b9jcEsBxMvA,14233
|
|
4
4
|
line/bus.py,sha256=AGMwHCNftVeWDzl0o1Jd2iDm51q1FTBEOlJzGZLub1Q,14324
|
|
5
5
|
line/call_request.py,sha256=JfYS4bAicuwjTHMXdK_PYbvSPj1dht44282yZACGUbU,847
|
|
@@ -23,10 +23,12 @@ line/tools/system_tools.py,sha256=1RkZv_iGAF2LvR3ddoa7Cz4VmC_Xh-BPjmQGKdCu5Fc,86
|
|
|
23
23
|
line/tools/tool_types.py,sha256=JJ6mfH9wB-dtaQFHkm8vsJIYsAiW6bIpgAh_nmDn744,1066
|
|
24
24
|
line/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
25
|
line/utils/aio.py,sha256=l1YgZ8tf1KtOyq6A-6RA_kE-FKGafDfHERFnDx0-PxM,1899
|
|
26
|
-
line/utils/
|
|
26
|
+
line/utils/dtmf_lookahead_buffer.py,sha256=w6PZ8kJfXOZCQ3EpWRzjt-3x_CvEYWkg3oY7KcaDAeU,4571
|
|
27
|
+
line/utils/gemini_utils.py,sha256=FPu3t4oPa8L_vjxB6d1MPjUhzaUSkpBVRmqknCiEfX8,5741
|
|
28
|
+
line/utils/log_aiter.py,sha256=allMNfjku0tWmD5exEN2imP0YlLoUtIkQTzI1ZQ8yyE,4177
|
|
27
29
|
line/utils/openai_utils.py,sha256=I9nIpHTFC98ChWzRmV-enMSIcicRVF9KYjifsWkoPCE,3596
|
|
28
30
|
line/utils/str.py,sha256=b0HCAMdqHh5S8KVulqLwT3x7dj6Tdgaw2WNPAw1gzEY,860
|
|
29
|
-
cartesia_line-0.1.
|
|
30
|
-
cartesia_line-0.1.
|
|
31
|
-
cartesia_line-0.1.
|
|
32
|
-
cartesia_line-0.1.
|
|
31
|
+
cartesia_line-0.1.4.dist-info/METADATA,sha256=qVxR5Wwa0G0gi9xstUQcoUIZ8cs10l5Ov7KHaPpTDSM,4402
|
|
32
|
+
cartesia_line-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
33
|
+
cartesia_line-0.1.4.dist-info/top_level.txt,sha256=xztzr4hR6ekbxrTcEufazgor-5McHQuLNu82cxn1jNE,5
|
|
34
|
+
cartesia_line-0.1.4.dist-info/RECORD,,
|
line/__init__.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
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
|
|
7
6
|
from line.nodes.conversation_context import ConversationContext
|
|
8
7
|
|
|
9
8
|
# Reasoning components
|
|
@@ -27,9 +26,4 @@ __all__ = [
|
|
|
27
26
|
"VoiceAgentApp",
|
|
28
27
|
"VoiceAgentSystem",
|
|
29
28
|
"register_observability_event",
|
|
30
|
-
"AgentTurn",
|
|
31
|
-
"ConversationRunner",
|
|
32
|
-
"Turn",
|
|
33
|
-
"UserTurn",
|
|
34
|
-
"SimilarityUtils",
|
|
35
29
|
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
import re
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
from line.events import AgentResponse, DTMFOutputEvent
|
|
6
|
+
|
|
7
|
+
DTMF_EXPRESSION = "dtmf="
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DTMFLookAheadStringBuffer:
|
|
11
|
+
"""
|
|
12
|
+
Wrapper ontop of DTMFLookAheadCharacterBuffer, but will yield strings instead of characters
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.buffer = DTMFLookAheadCharacterBuffer()
|
|
17
|
+
|
|
18
|
+
def feed(self, string: str) -> Generator[Union[AgentResponse, DTMFOutputEvent], None, None]:
|
|
19
|
+
for char in string:
|
|
20
|
+
for item in self.buffer.feed(char):
|
|
21
|
+
if isinstance(item, DTMFOutputEvent):
|
|
22
|
+
for digit in item.button:
|
|
23
|
+
yield DTMFOutputEvent(button=digit)
|
|
24
|
+
else:
|
|
25
|
+
yield item
|
|
26
|
+
|
|
27
|
+
def flush(self) -> Generator[Union[AgentResponse, DTMFOutputEvent], None, None]:
|
|
28
|
+
for item in self.buffer.flush():
|
|
29
|
+
if isinstance(item, DTMFOutputEvent):
|
|
30
|
+
for digit in split_dtmf_output(item):
|
|
31
|
+
yield digit
|
|
32
|
+
else:
|
|
33
|
+
yield item
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def split_dtmf_output(item: DTMFOutputEvent) -> Generator[DTMFOutputEvent, None, None]:
|
|
37
|
+
"""
|
|
38
|
+
DTMFOutputEvent(button="12") -> [DTMFOutputEvent(button="1"), DTMFOutputEvent(button="2")]
|
|
39
|
+
"""
|
|
40
|
+
for digit in item.button:
|
|
41
|
+
yield DTMFOutputEvent(button=digit)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DTMFLookAheadCharacterBuffer:
|
|
45
|
+
"""
|
|
46
|
+
A look ahead buffer that will replace DTMF expressions with DTMF output events
|
|
47
|
+
|
|
48
|
+
Why do we have this:
|
|
49
|
+
- Sometimes, gemini will yield ["hello dtmf", "=123 world"]
|
|
50
|
+
- We want to yield [AgentResponse("hello"), DTMFOutputEvent("123"), AgentResponse("world")]
|
|
51
|
+
- This is a look ahead buffer, so we need to keep track of the buffer and the chunks
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.non_dtmf_buffer = ""
|
|
56
|
+
self.dtmf_buffer = ""
|
|
57
|
+
|
|
58
|
+
self.chunks = []
|
|
59
|
+
|
|
60
|
+
self.full_expression = re.compile(r"dtmf=(\d+)")
|
|
61
|
+
self.dtmf_preamble = "dtmf="
|
|
62
|
+
|
|
63
|
+
def feed(self, char: str) -> Generator[Union[AgentResponse, DTMFOutputEvent], None, None]:
|
|
64
|
+
"""
|
|
65
|
+
Feed a character into the buffer and see if we yield anything
|
|
66
|
+
"""
|
|
67
|
+
# new character is in a word boundary: flush
|
|
68
|
+
is_word_char = re.match(r"[\w0-9=]", char)
|
|
69
|
+
# No match and will not be a match: buffer next char onto non-dtmf buffer
|
|
70
|
+
if not self.dtmf_buffer and not self.dtmf_preamble.startswith(char):
|
|
71
|
+
self.non_dtmf_buffer += char
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# No match and might be a match: buffer next char to dtmf buffer
|
|
75
|
+
if not self.dtmf_buffer and self.dtmf_preamble.startswith(char):
|
|
76
|
+
self.dtmf_buffer += char
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if self.dtmf_buffer and not is_word_char:
|
|
80
|
+
# We are in a match, flush both
|
|
81
|
+
if self.dtmf_buffer.startswith(self.dtmf_preamble):
|
|
82
|
+
if self.non_dtmf_buffer:
|
|
83
|
+
yield AgentResponse(content=self.non_dtmf_buffer)
|
|
84
|
+
|
|
85
|
+
captured = self.dtmf_buffer.replace(self.dtmf_preamble, "", 1)
|
|
86
|
+
yield DTMFOutputEvent(
|
|
87
|
+
button=captured,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
else:
|
|
91
|
+
# Otherwise, we didn't build enough in the dtmf buffer so move the buffer and then flush
|
|
92
|
+
to_flush = self.non_dtmf_buffer + self.dtmf_buffer
|
|
93
|
+
if to_flush:
|
|
94
|
+
yield AgentResponse(content=to_flush)
|
|
95
|
+
|
|
96
|
+
# Next reset
|
|
97
|
+
self.non_dtmf_buffer = ""
|
|
98
|
+
self.dtmf_buffer = ""
|
|
99
|
+
|
|
100
|
+
# Finally, recursively feed the character back in
|
|
101
|
+
for item in self.feed(char):
|
|
102
|
+
yield item
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# New character is not in a word boundary, keep accumulating and checking
|
|
106
|
+
if self.dtmf_buffer and is_word_char:
|
|
107
|
+
self.dtmf_buffer += char
|
|
108
|
+
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
raise RuntimeError(f"Invalid state: {char=} {self.dtmf_buffer=} {self.non_dtmf_buffer=}")
|
|
112
|
+
|
|
113
|
+
def flush(self) -> Generator[Union[AgentResponse, DTMFOutputEvent], None, None]:
|
|
114
|
+
if self.dtmf_buffer.startswith(self.dtmf_preamble):
|
|
115
|
+
captured = self.dtmf_buffer.replace(self.dtmf_preamble, "", 1)
|
|
116
|
+
if self.non_dtmf_buffer:
|
|
117
|
+
yield AgentResponse(content=self.non_dtmf_buffer)
|
|
118
|
+
|
|
119
|
+
yield DTMFOutputEvent(button=captured)
|
|
120
|
+
else:
|
|
121
|
+
content = self.non_dtmf_buffer + self.dtmf_buffer
|
|
122
|
+
if content:
|
|
123
|
+
yield AgentResponse(content=content)
|
|
124
|
+
|
|
125
|
+
# Cleanup
|
|
126
|
+
self.non_dtmf_buffer = ""
|
|
127
|
+
self.dtmf_buffer = ""
|
|
128
|
+
return
|
line/utils/gemini_utils.py
CHANGED
|
@@ -11,6 +11,8 @@ from loguru import logger
|
|
|
11
11
|
|
|
12
12
|
from line.events import (
|
|
13
13
|
AgentResponse,
|
|
14
|
+
DTMFInputEvent,
|
|
15
|
+
DTMFOutputEvent,
|
|
14
16
|
EventInstance,
|
|
15
17
|
EventType,
|
|
16
18
|
ToolResult,
|
|
@@ -65,6 +67,10 @@ def convert_messages_to_gemini(
|
|
|
65
67
|
gemini_messages.append(types.ModelContent(parts=[types.Part.from_text(text=event.content)]))
|
|
66
68
|
elif isinstance(event, UserTranscriptionReceived):
|
|
67
69
|
gemini_messages.append(types.UserContent(parts=[types.Part.from_text(text=event.content)]))
|
|
70
|
+
elif isinstance(event, DTMFInputEvent):
|
|
71
|
+
gemini_messages.append(types.UserContent(parts=[types.Part.from_text(text=event.button)]))
|
|
72
|
+
elif isinstance(event, DTMFOutputEvent):
|
|
73
|
+
gemini_messages.append(types.ModelContent(parts=[types.Part.from_text(text=event.button)]))
|
|
68
74
|
elif isinstance(event, ToolResult):
|
|
69
75
|
# Gemini 400s if a ToolResult is the first message in the context, so don't add it:
|
|
70
76
|
if not gemini_messages:
|
line/utils/log_aiter.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
import datetime
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def log_afunc(log_result=False, message=None):
|
|
8
|
+
"""Decorator to log execution time of async functions
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
log_result: If True, also log the function result
|
|
12
|
+
message: Optional message to include in log statements for additional context
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def decorator(func):
|
|
16
|
+
async def wrapper(*args, **kwargs):
|
|
17
|
+
# Use custom message if provided, otherwise use function name
|
|
18
|
+
log_message = message or func.__name__
|
|
19
|
+
|
|
20
|
+
logger.info(f"[START FUNC] {log_message}")
|
|
21
|
+
start_time = datetime.datetime.now()
|
|
22
|
+
try:
|
|
23
|
+
result = await func(*args, **kwargs)
|
|
24
|
+
end_time = datetime.datetime.now()
|
|
25
|
+
duration = (end_time - start_time).total_seconds()
|
|
26
|
+
logger.info(f"[END FUNC] {log_message}. elapsed_time={duration:.2f}s")
|
|
27
|
+
if log_result:
|
|
28
|
+
logger.info(f"[RESULT FUNC] {log_message}. {result=}")
|
|
29
|
+
return result
|
|
30
|
+
except Exception as e:
|
|
31
|
+
raise e
|
|
32
|
+
|
|
33
|
+
return wrapper
|
|
34
|
+
|
|
35
|
+
return decorator
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def log_aiter_func(message=None, show_each=False):
|
|
39
|
+
"""Decorator to log timing information for async generator functions
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
message: Optional message to include in log statements for additional context
|
|
43
|
+
measure_each: If True, log time between each iteration
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
@time_aiter_func(message="Processing data", measure_each=True)
|
|
47
|
+
async def my_generator():
|
|
48
|
+
yield item
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def decorator(func):
|
|
52
|
+
async def wrapper(*args, **kwargs):
|
|
53
|
+
# Use custom message if provided, otherwise use function name
|
|
54
|
+
log_message = message or func.__name__
|
|
55
|
+
|
|
56
|
+
logger.info(f"[START ITER] {log_message}")
|
|
57
|
+
start_time = datetime.datetime.now()
|
|
58
|
+
item_count = 0
|
|
59
|
+
last_time = start_time
|
|
60
|
+
iter_timings = []
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
async for item in func(*args, **kwargs):
|
|
64
|
+
current_time = datetime.datetime.now()
|
|
65
|
+
time_since_last = (current_time - last_time).total_seconds()
|
|
66
|
+
iter_timings.append(round(time_since_last, 2))
|
|
67
|
+
|
|
68
|
+
if item_count == 0:
|
|
69
|
+
logger.info(
|
|
70
|
+
f"[FIRST_ITEM ITER] {log_message}. time_to_first_item={time_since_last:.2f}s"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
item_count += 1
|
|
74
|
+
last_time = current_time
|
|
75
|
+
yield item
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.exception(f"[ERROR] {log_message}. {e}")
|
|
78
|
+
raise e
|
|
79
|
+
|
|
80
|
+
finally:
|
|
81
|
+
end_time = datetime.datetime.now()
|
|
82
|
+
total_duration = (end_time - start_time).total_seconds()
|
|
83
|
+
|
|
84
|
+
# Use iter_timings for all timing information
|
|
85
|
+
time_to_first_item = f"{iter_timings[0]:.2f}s" if iter_timings else "0s"
|
|
86
|
+
|
|
87
|
+
displayed_timings = iter_timings if show_each else []
|
|
88
|
+
|
|
89
|
+
logger.info(
|
|
90
|
+
f"[END ITER] {log_message}. items_yielded_count={item_count},"
|
|
91
|
+
+ f" total_time={total_duration:.2f}s, time_to_first_item={time_to_first_item},"
|
|
92
|
+
+ f" iter_timings={displayed_timings}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return wrapper
|
|
96
|
+
|
|
97
|
+
return decorator
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@contextmanager
|
|
101
|
+
def context_log(message: str):
|
|
102
|
+
"""
|
|
103
|
+
Context manager that logs entry and exit messages.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
message: The message to log (will be prefixed with [ENTER] and [EXIT])
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
with context_logger("Starting service"):
|
|
110
|
+
# do something
|
|
111
|
+
"""
|
|
112
|
+
logger.info(f"[START] {message}")
|
|
113
|
+
start_time = datetime.datetime.now()
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
yield
|
|
117
|
+
except Exception as e:
|
|
118
|
+
duration = (datetime.datetime.now() - start_time).total_seconds()
|
|
119
|
+
logger.exception(f"[ERROR] {message}. duration={duration:.2f}s. {e}")
|
|
120
|
+
raise
|
|
121
|
+
finally:
|
|
122
|
+
duration = (datetime.datetime.now() - start_time).total_seconds()
|
|
123
|
+
logger.info(f"[EXIT] {message}. duration={duration:.2f}s")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|