lucidicai 1.3.5__py3-none-any.whl → 2.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.
lucidicai/dataset.py ADDED
@@ -0,0 +1,112 @@
1
+ import os
2
+ import logging
3
+ from typing import Optional, Dict, List, Any
4
+ from dotenv import load_dotenv
5
+
6
+ from .client import Client
7
+ from .errors import APIKeyVerificationError
8
+
9
+ logger = logging.getLogger("Lucidic")
10
+
11
+
12
+ def get_dataset(
13
+ dataset_id: str,
14
+ api_key: Optional[str] = None,
15
+ agent_id: Optional[str] = None,
16
+ ) -> Dict[str, Any]:
17
+ """
18
+ Get a dataset by ID with all its items.
19
+
20
+ Args:
21
+ dataset_id: The ID of the dataset to retrieve (required).
22
+ api_key: API key for authentication. If not provided, will use the LUCIDIC_API_KEY environment variable.
23
+ agent_id: Agent ID. If not provided, will use the LUCIDIC_AGENT_ID environment variable.
24
+
25
+ Returns:
26
+ A dictionary containing the dataset information including:
27
+ - dataset_id: The dataset ID
28
+ - name: Dataset name
29
+ - description: Dataset description
30
+ - tags: List of tags
31
+ - created_at: Creation timestamp
32
+ - updated_at: Last update timestamp
33
+ - num_items: Number of items in the dataset
34
+ - items: List of dataset items
35
+
36
+ Raises:
37
+ APIKeyVerificationError: If API key or agent ID is missing or invalid.
38
+ ValueError: If dataset_id is not provided.
39
+ """
40
+ load_dotenv()
41
+
42
+ # Validation
43
+ if not dataset_id:
44
+ raise ValueError("Dataset ID is required")
45
+
46
+ # Get credentials
47
+ if api_key is None:
48
+ api_key = os.getenv("LUCIDIC_API_KEY", None)
49
+ if api_key is None:
50
+ raise APIKeyVerificationError(
51
+ "Make sure to either pass your API key into get_dataset() or set the LUCIDIC_API_KEY environment variable."
52
+ )
53
+
54
+ if agent_id is None:
55
+ agent_id = os.getenv("LUCIDIC_AGENT_ID", None)
56
+ if agent_id is None:
57
+ raise APIKeyVerificationError(
58
+ "Lucidic agent ID not specified. Make sure to either pass your agent ID into get_dataset() or set the LUCIDIC_AGENT_ID environment variable."
59
+ )
60
+
61
+ # Get current client or create a new one
62
+ client = Client()
63
+ # If not yet initialized or still the NullClient -> create a real client
64
+ if not getattr(client, 'initialized', False):
65
+ client = Client(api_key=api_key, agent_id=agent_id)
66
+ else:
67
+ # Already initialized, check if we need to update credentials
68
+ if api_key is not None and agent_id is not None and (api_key != client.api_key or agent_id != client.agent_id):
69
+ client.set_api_key(api_key)
70
+ client.agent_id = agent_id
71
+
72
+ # Make request to get dataset
73
+ response = client.make_request(
74
+ 'getdataset',
75
+ 'GET',
76
+ {'dataset_id': dataset_id}
77
+ )
78
+
79
+ logger.info(f"Retrieved dataset {dataset_id} with {response.get('num_items', 0)} items")
80
+ return response
81
+
82
+
83
+ def get_dataset_items(
84
+ dataset_id: str,
85
+ api_key: Optional[str] = None,
86
+ agent_id: Optional[str] = None,
87
+ ) -> List[Dict[str, Any]]:
88
+ """
89
+ Convenience function to get just the items from a dataset.
90
+
91
+ Args:
92
+ dataset_id: The ID of the dataset to retrieve items from (required).
93
+ api_key: API key for authentication. If not provided, will use the LUCIDIC_API_KEY environment variable.
94
+ agent_id: Agent ID. If not provided, will use the LUCIDIC_AGENT_ID environment variable.
95
+
96
+ Returns:
97
+ A list of dataset items, where each item contains:
98
+ - dataset_item_id: The item ID
99
+ - name: Item name
100
+ - description: Item description
101
+ - tags: List of tags
102
+ - input: Input data for the item
103
+ - expected_output: Expected output data
104
+ - metadata: Additional metadata
105
+ - created_at: Creation timestamp
106
+
107
+ Raises:
108
+ APIKeyVerificationError: If API key or agent ID is missing or invalid.
109
+ ValueError: If dataset_id is not provided.
110
+ """
111
+ dataset = get_dataset(dataset_id, api_key, agent_id)
112
+ return dataset.get('items', [])
lucidicai/decorators.py CHANGED
@@ -1,375 +1,146 @@
1
- """Decorators for the Lucidic SDK to simplify step and event tracking."""
1
+ """Decorators for the Lucidic SDK to create typed, nested events."""
2
2
  import functools
3
- import contextvars
4
3
  import inspect
5
4
  import json
6
5
  import logging
7
- from typing import Any, Callable, Optional, TypeVar, Union
6
+ from datetime import datetime
7
+ import uuid
8
+ from typing import Any, Callable, Optional, TypeVar
8
9
  from collections.abc import Iterable
9
10
 
10
11
  from .client import Client
11
12
  from .errors import LucidicNotInitializedError
13
+ from .context import current_parent_event_id, event_context, event_context_async
12
14
 
13
15
  logger = logging.getLogger("Lucidic")
14
16
 
15
17
  F = TypeVar('F', bound=Callable[..., Any])
16
18
 
17
- # Create context variables to store the current step and event
18
- _current_step = contextvars.ContextVar("current_step", default=None)
19
- _current_event = contextvars.ContextVar("current_event", default=None)
20
19
 
21
- def get_decorator_step():
22
- return _current_step.get()
20
+ def _serialize(value: Any):
21
+ if isinstance(value, (str, int, float, bool)):
22
+ return value
23
+ if isinstance(value, dict):
24
+ return {k: _serialize(v) for k, v in value.items()}
25
+ if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
26
+ return [_serialize(v) for v in value]
27
+ try:
28
+ return json.loads(json.dumps(value, default=str))
29
+ except Exception:
30
+ return str(value)
23
31
 
24
- def get_decorator_event():
25
- return _current_event.get()
26
32
 
33
+ def event(**decorator_kwargs) -> Callable[[F], F]:
34
+ """Universal decorator creating FUNCTION_CALL events with nesting and error capture."""
27
35
 
28
- def step(
29
- state: Optional[str] = None,
30
- action: Optional[str] = None,
31
- goal: Optional[str] = None,
32
- screenshot_path: Optional[str] = None,
33
- eval_score: Optional[float] = None,
34
- eval_description: Optional[str] = None
35
- ) -> Callable[[F], F]:
36
- """
37
- Decorator that wraps a function with step tracking.
38
-
39
- The decorated function will be wrapped with create_step() at the start
40
- and end_step() at the end, ensuring proper cleanup even on exceptions.
41
-
42
- Args:
43
- state: State description for the step
44
- action: Action description for the step
45
- goal: Goal description for the step
46
- eval_score: Evaluation score for the step
47
- eval_description: Evaluation description for the step
48
-
49
- Example:
50
- @lai.step(
51
- state="Processing user input",
52
- action="Validate and parse request",
53
- goal="Extract intent from user message"
54
- )
55
- def process_user_input(message: str) -> dict:
56
- # Function logic here
57
- return parsed_intent
58
- """
59
36
  def decorator(func: F) -> F:
60
37
  @functools.wraps(func)
61
38
  def sync_wrapper(*args, **kwargs):
62
- # Check if SDK is initialized
63
39
  try:
64
40
  client = Client()
65
41
  if not client.session:
66
- # No active session, run function normally
67
- logger.warning("No active session, running function normally")
68
42
  return func(*args, **kwargs)
69
- except LucidicNotInitializedError:
70
- # SDK not initialized, run function normally
71
- logger.warning("Lucidic not initialized, running function normally")
43
+ except (LucidicNotInitializedError, AttributeError):
72
44
  return func(*args, **kwargs)
73
-
74
- # Create the step
75
- step_params = {
76
- 'state': state,
77
- 'action': action,
78
- 'goal': goal,
79
- 'screenshot_path': screenshot_path,
80
- 'eval_score': eval_score,
81
- 'eval_description': eval_description
82
- }
83
- # Remove None values
84
- step_params = {k: v for k, v in step_params.items() if v is not None}
85
-
86
- # Import here to avoid circular imports
87
- from . import create_step, end_step
88
- step_id = create_step(**step_params)
89
- tok = _current_step.set(step_id)
90
-
45
+
46
+ # Build arguments snapshot
47
+ sig = inspect.signature(func)
48
+ bound = sig.bind(*args, **kwargs)
49
+ bound.apply_defaults()
50
+ args_dict = {name: _serialize(val) for name, val in bound.arguments.items()}
51
+
52
+ parent_id = current_parent_event_id.get(None)
53
+ pre_event_id = str(uuid.uuid4())
54
+ start_time = datetime.now().astimezone()
55
+ result = None
56
+ error: Optional[BaseException] = None
57
+
91
58
  try:
92
- # Execute the wrapped function
93
- result = func(*args, **kwargs)
94
- # End step successfully
95
- end_step(step_id=step_id)
96
- _current_step.reset(tok)
59
+ with event_context(pre_event_id):
60
+ result = func(*args, **kwargs)
97
61
  return result
98
62
  except Exception as e:
99
- # End step with error indication
63
+ error = e
64
+ raise
65
+ finally:
100
66
  try:
101
- end_step(
102
- step_id=step_id,
103
- eval_score=0.0,
104
- eval_description=f"Step failed with error: {str(e)}"
67
+ # Store error as return value with type information
68
+ if error:
69
+ return_val = {
70
+ "error": str(error),
71
+ "error_type": type(error).__name__
72
+ }
73
+ else:
74
+ return_val = _serialize(result)
75
+
76
+ client.create_event(
77
+ type="function_call",
78
+ event_id=pre_event_id,
79
+ function_name=func.__name__,
80
+ arguments=args_dict,
81
+ return_value=return_val,
82
+ error=str(error) if error else None,
83
+ parent_event_id=parent_id,
84
+ duration=(datetime.now().astimezone() - start_time).total_seconds(),
85
+ **decorator_kwargs
105
86
  )
106
- _current_step.reset(tok)
107
87
  except Exception:
108
- # If end_step fails, just log it
109
- logger.error(f"Failed to end step {step_id} after error")
110
- raise
111
-
88
+ pass
89
+
112
90
  @functools.wraps(func)
113
91
  async def async_wrapper(*args, **kwargs):
114
- # Check if SDK is initialized
115
92
  try:
116
93
  client = Client()
117
94
  if not client.session:
118
- # No active session, run function normally
119
- logger.warning("No active session, running function normally")
120
95
  return await func(*args, **kwargs)
121
- except LucidicNotInitializedError:
122
- # SDK not initialized, run function normally
123
- logger.warning("Lucidic not initialized, running function normally")
96
+ except (LucidicNotInitializedError, AttributeError):
124
97
  return await func(*args, **kwargs)
125
-
126
- # Create the step
127
- step_params = {
128
- 'state': state,
129
- 'action': action,
130
- 'goal': goal,
131
- 'screenshot_path': screenshot_path,
132
- 'eval_score': eval_score,
133
- 'eval_description': eval_description
134
- }
135
- # Remove None values
136
- step_params = {k: v for k, v in step_params.items() if v is not None}
137
-
138
- # Import here to avoid circular imports
139
- from . import create_step, end_step
140
-
141
- step_id = create_step(**step_params)
142
- tok = _current_step.set(step_id)
143
- try:
144
- # Execute the wrapped function
145
- result = await func(*args, **kwargs)
146
- # End step successfully
147
- end_step(step_id=step_id)
148
- _current_step.reset(tok)
149
- return result
150
- except Exception as e:
151
- # End step with error indication
152
- try:
153
- end_step(
154
- step_id=step_id,
155
- eval_score=0.0,
156
- eval_description=f"Step failed with error: {str(e)}"
157
- )
158
- _current_step.reset(tok)
159
- except Exception:
160
- # If end_step fails, just log it
161
- logger.error(f"Failed to end step {step_id} after error")
162
- raise
163
-
164
- # Return appropriate wrapper based on function type
165
- if inspect.iscoroutinefunction(func):
166
- return async_wrapper
167
- else:
168
- return sync_wrapper
169
-
170
- return decorator
171
98
 
172
-
173
- ### -- TODO -- Updating even within function causes function result to not be recorded.
174
- def event(
175
- description: Optional[str] = None,
176
- result: Optional[str] = None,
177
- model: Optional[str] = None,
178
- cost_added: Optional[float] = 0
179
- ) -> Callable[[F], F]:
180
- """
181
- Decorator that creates an event for a function call.
182
-
183
- The decorated function will create an event that captures:
184
- - Function inputs (as string representation) if description not provided
185
- - Function output (as string representation) if result not provided
186
-
187
- LLM calls within the function will create their own events as normal.
188
-
189
- Args:
190
- description: Custom description for the event. If not provided,
191
- will use string representation of function inputs
192
- result: Custom result for the event. If not provided,
193
- will use string representation of function output
194
- model: Model name if this function represents a model call (default: None)
195
- cost_added: Cost to add for this event (default: 0)
196
-
197
- Example:
198
- @lai.event(description="Parse user query", model="custom-parser")
199
- def parse_query(query: str) -> dict:
200
- # Function logic here
201
- return {"intent": "search", "query": query}
202
- """
203
- def decorator(func: F) -> F:
204
- @functools.wraps(func)
205
- def sync_wrapper(*args, **kwargs):
206
- # Check if SDK is initialized
207
- try:
208
- client = Client()
209
- if not client.session:
210
- # No active session, run function normally
211
- logger.warning("No active session, running function normally")
212
- return func(*args, **kwargs)
213
- except (LucidicNotInitializedError, AttributeError):
214
- # SDK not initialized or no session, run function normally
215
- logger.warning("Lucidic not initialized, running function normally")
216
- return func(*args, **kwargs)
217
-
218
- # Import here to avoid circular imports
219
- from . import create_event, end_event
220
-
221
- # Build event description from inputs if not provided
222
- event_desc = description
223
- function_name = func.__name__
224
-
225
- # Get function signature
226
99
  sig = inspect.signature(func)
227
- bound_args = sig.bind(*args, **kwargs)
228
- bound_args.apply_defaults()
100
+ bound = sig.bind(*args, **kwargs)
101
+ bound.apply_defaults()
102
+ args_dict = {name: _serialize(val) for name, val in bound.arguments.items()}
229
103
 
230
- def serialize(value):
231
- if isinstance(value, str):
232
- return value
233
- if isinstance(value, int):
234
- return value
235
- if isinstance(value, float):
236
- return value
237
- if isinstance(value, bool):
238
- return value
239
- if isinstance(value, dict):
240
- return {k: serialize(v) for k, v in value.items()}
241
- if isinstance(value, Iterable):
242
- return [serialize(v) for v in value]
243
- return str(value)
104
+ parent_id = current_parent_event_id.get(None)
105
+ pre_event_id = str(uuid.uuid4())
106
+ start_time = datetime.now().astimezone()
107
+ result = None
108
+ error: Optional[BaseException] = None
244
109
 
245
- # Construct JSONable object of args
246
- args_dict = {
247
- param_name: serialize(param_value) # Recursive - maybe change later
248
- for param_name, param_value in bound_args.arguments.items()
249
- }
250
-
251
- if not event_desc:
252
- event_desc = f"Function {function_name}({json.dumps(args_dict)})"
253
-
254
- # Create the event
255
- event_id = create_event(
256
- description=event_desc,
257
- model=model,
258
- cost_added=cost_added,
259
- function_name=function_name,
260
- arguments=args_dict,
261
- )
262
- tok = _current_event.set(event_id)
263
110
  try:
264
- # Execute the wrapped function
265
- function_result = func(*args, **kwargs)
266
-
267
- # Build event result from output if not provided
268
- event_result = result
269
- if not event_result:
270
- try:
271
- event_result = repr(function_result)
272
- except Exception:
273
- event_result = str(function_result)
274
-
275
- # Update and end the event
276
- end_event(
277
- event_id=event_id,
278
- result=event_result,
279
- )
280
- _current_event.reset(tok)
281
- return function_result
282
-
111
+ async with event_context_async(pre_event_id):
112
+ result = await func(*args, **kwargs)
113
+ return result
283
114
  except Exception as e:
284
- # Update event with error
285
- try:
286
- end_event(
287
- event_id=event_id,
288
- result=f"Error: {str(e)}",
289
- )
290
- _current_event.reset(tok)
291
- except Exception:
292
- logger.error(f"Failed to end event {event_id} after error")
115
+ error = e
293
116
  raise
294
-
295
- @functools.wraps(func)
296
- async def async_wrapper(*args, **kwargs):
297
- # Check if SDK is initialized
298
- try:
299
- client = Client()
300
- if not client.session:
301
- # No active session, run function normally
302
- logger.warning("No active session, running function normally")
303
- return await func(*args, **kwargs)
304
- except (LucidicNotInitializedError, AttributeError):
305
- # SDK not initialized or no session, run function normally
306
- logger.warning("Lucidic not initialized, running function normally")
307
- return await func(*args, **kwargs)
308
-
309
- # Import here to avoid circular imports
310
- from . import create_event, end_event
311
-
312
- # Build event description from inputs if not provided
313
- event_desc = description
314
- if not event_desc:
315
- # Get function signature
316
- sig = inspect.signature(func)
317
- bound_args = sig.bind(*args, **kwargs)
318
- bound_args.apply_defaults()
319
-
320
- # Create string representation of inputs
321
- input_parts = []
322
- for param_name, param_value in bound_args.arguments.items():
323
- try:
324
- input_parts.append(f"{param_name}={repr(param_value)}")
325
- except Exception:
326
- input_parts.append(f"{param_name}=<{type(param_value).__name__}>")
327
-
328
- event_desc = f"{func.__name__}({', '.join(input_parts)})"
329
-
330
- # Create the event
331
- event_id = create_event(
332
- description=event_desc,
333
- model=model,
334
- cost_added=cost_added
335
- )
336
- tok = _current_event.set(event_id)
337
- try:
338
- # Execute the wrapped function
339
- function_result = await func(*args, **kwargs)
340
-
341
- # Build event result from output if not provided
342
- event_result = result
343
- if not event_result:
344
- try:
345
- event_result = repr(function_result)
346
- except Exception:
347
- event_result = str(function_result)
348
-
349
- # Update and end the event
350
- end_event(
351
- event_id=event_id,
352
- result=event_result,
353
- )
354
- _current_event.reset(tok)
355
- return function_result
356
-
357
- except Exception as e:
358
- # Update event with error
117
+ finally:
359
118
  try:
360
- end_event(
361
- event_id=event_id,
362
- result=f"Error: {str(e)}",
119
+ # Store error as return value with type information
120
+ if error:
121
+ return_val = {
122
+ "error": str(error),
123
+ "error_type": type(error).__name__
124
+ }
125
+ else:
126
+ return_val = _serialize(result)
127
+
128
+ client.create_event(
129
+ type="function_call",
130
+ event_id=pre_event_id,
131
+ function_name=func.__name__,
132
+ arguments=args_dict,
133
+ return_value=return_val,
134
+ error=str(error) if error else None,
135
+ parent_event_id=parent_id,
136
+ duration=(datetime.now().astimezone() - start_time).total_seconds(),
137
+ **decorator_kwargs
363
138
  )
364
- _current_event.reset(tok)
365
139
  except Exception:
366
- logger.error(f"Failed to end event {event_id} after error")
367
- raise
368
-
369
- # Return appropriate wrapper based on function type
140
+ pass
141
+
370
142
  if inspect.iscoroutinefunction(func):
371
- return async_wrapper
372
- else:
373
- return sync_wrapper
374
-
143
+ return async_wrapper # type: ignore
144
+ return sync_wrapper # type: ignore
145
+
375
146
  return decorator
lucidicai/errors.py CHANGED
@@ -1,4 +1,6 @@
1
1
  from typing import Optional
2
+ import sys
3
+ import traceback
2
4
 
3
5
  class APIKeyVerificationError(Exception):
4
6
  """Exception for API key verification errors"""
@@ -19,3 +21,34 @@ class InvalidOperationError(Exception):
19
21
  "Exception for errors resulting from attempting an invalid operation"
20
22
  def __init__(self, message: str):
21
23
  super().__init__(f"An invalid Lucidic operation was attempted: {message}")
24
+
25
+
26
+ def install_error_handler():
27
+ """Install global handler to create ERROR_TRACEBACK events for uncaught exceptions."""
28
+ from .client import Client
29
+ from .context import current_parent_event_id
30
+
31
+ def handle_exception(exc_type, exc_value, exc_traceback):
32
+ try:
33
+ client = Client()
34
+ if client and client.session:
35
+ tb = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
36
+ parent_id = None
37
+ try:
38
+ parent_id = current_parent_event_id.get(None)
39
+ except Exception:
40
+ parent_id = None
41
+ client.create_event(
42
+ type="error_traceback",
43
+ error=str(exc_value),
44
+ traceback=tb,
45
+ parent_event_id=parent_id
46
+ )
47
+ except Exception:
48
+ pass
49
+ try:
50
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
51
+ except Exception:
52
+ pass
53
+
54
+ sys.excepthook = handle_exception