aiqa-client 0.2.1__py3-none-any.whl → 0.3.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.
@@ -0,0 +1,396 @@
1
+ """
2
+ Object serialization utilities for converting Python objects to JSON-safe formats.
3
+ Handles objects, dataclasses, circular references, and size limits.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import dataclasses
9
+ import logging
10
+ from datetime import datetime, date, time
11
+ from typing import Any, Callable, Set
12
+
13
+ logger = logging.getLogger("aiqa")
14
+
15
+ def toNumber(value: str|int|None) -> int:
16
+ """Convert string to number. handling units like g, m, k, (also mb kb gb though these should be avoided)"""
17
+ if value is None:
18
+ return 0
19
+ if isinstance(value, int):
20
+ return value
21
+ if value.endswith("b"): # drop the b
22
+ value = value[:-1]
23
+ if value.endswith("g"):
24
+ return int(value[:-1]) * 1024 * 1024 * 1024
25
+ elif value.endswith("m"):
26
+ return int(value[:-1]) * 1024 * 1024
27
+ elif value.endswith("k"):
28
+ return int(value[:-1]) * 1024
29
+ return int(value)
30
+
31
+
32
+ # Configurable limit for object string representation (in characters)
33
+ AIQA_MAX_OBJECT_STR_CHARS = toNumber(os.getenv("AIQA_MAX_OBJECT_STR_CHARS", "1m"))
34
+
35
+ # Data filters configuration
36
+ def _get_enabled_filters() -> Set[str]:
37
+ """Get set of enabled filter names from AIQA_DATA_FILTERS env var."""
38
+ filters_env = os.getenv("AIQA_DATA_FILTERS", "RemovePasswords, RemoveJWT, RemoveAuthHeaders, RemoveAPIKeys")
39
+ if not filters_env or filters_env.lower() == "false":
40
+ return set()
41
+ return {f.strip() for f in filters_env.split(",") if f.strip()}
42
+
43
+ _ENABLED_FILTERS = _get_enabled_filters()
44
+
45
+ def _is_jwt_token(value: Any) -> bool:
46
+ """Check if a value looks like a JWT token (starts with 'eyJ' and has 3 parts separated by dots)."""
47
+ if not isinstance(value, str):
48
+ return False
49
+ # JWT tokens have format: header.payload.signature (3 parts separated by dots)
50
+ # They typically start with 'eyJ' (base64 encoded '{"')
51
+ parts = value.split('.')
52
+ return len(parts) == 3 and value.startswith('eyJ') and all(len(p) > 0 for p in parts)
53
+
54
+ def _is_api_key(value: Any) -> bool:
55
+ """Check if a value looks like an API key based on common patterns."""
56
+ if not isinstance(value, str):
57
+ return False
58
+ value = value.strip()
59
+ # Common API key prefixes:
60
+ api_key_prefixes = [
61
+ 'sk-', # OpenAI secret key
62
+ 'pk-', # possibly public key
63
+ 'AKIA', # AWS access key
64
+ 'ghp_', # GitHub personal access token
65
+ 'gho_', # GitHub OAuth token
66
+ 'ghu_', # GitHub unidentified token
67
+ 'ghs_', # GitHub SAML token
68
+ 'ghr_' # GitHub refresh token
69
+ ]
70
+ return any(value.startswith(prefix) for prefix in api_key_prefixes)
71
+
72
+ def _apply_data_filters(key: str, value: Any) -> Any:
73
+ """Apply data filters to a key-value pair based on enabled filters."""
74
+ if not value: # Don't filter falsy values
75
+ return value
76
+
77
+ key_lower = str(key).lower()
78
+
79
+ # RemovePasswords filter: if key contains "password", replace value with "****"
80
+ if "RemovePasswords" in _ENABLED_FILTERS and "password" in key_lower:
81
+ return "****"
82
+
83
+ # RemoveJWT filter: if value looks like a JWT token, replace with "****"
84
+ if "RemoveJWT" in _ENABLED_FILTERS and _is_jwt_token(value):
85
+ return "****"
86
+
87
+ # RemoveAuthHeaders filter: if key is "authorization" (case-insensitive), replace value with "****"
88
+ if "RemoveAuthHeaders" in _ENABLED_FILTERS and key_lower == "authorization":
89
+ return "****"
90
+
91
+ # RemoveAPIKeys filter: if key contains API key patterns or value looks like an API key
92
+ if "RemoveAPIKeys" in _ENABLED_FILTERS:
93
+ # Check key patterns
94
+ api_key_key_patterns = ['api_key', 'apikey', 'api-key', 'apikey']
95
+ if any(pattern in key_lower for pattern in api_key_key_patterns):
96
+ return "****"
97
+ # Check value patterns
98
+ if _is_api_key(value):
99
+ return "****"
100
+
101
+ return value
102
+
103
+
104
+ def serialize_for_span(value: Any) -> Any:
105
+ """
106
+ Serialize a value for span attributes.
107
+ OpenTelemetry only accepts primitives (bool, str, bytes, int, float) or sequences of those.
108
+ Complex types (dicts, lists, objects) are converted to JSON strings.
109
+
110
+ Handles objects by attempting to convert them to dicts, with safeguards against:
111
+ - Circular references
112
+ - Unconvertible parts
113
+ - Large objects (size limits)
114
+ """
115
+ # Keep primitives as is (including None)
116
+ if value is None or isinstance(value, (str, int, float, bool, bytes)):
117
+ return value
118
+
119
+ # For sequences, check if all elements are primitives
120
+ if isinstance(value, (list, tuple)):
121
+ # If all elements are primitives, return as list
122
+ if all(isinstance(item, (str, int, float, bool, bytes, type(None))) for item in value):
123
+ return list(value)
124
+ # Otherwise serialize to JSON string
125
+ try:
126
+ return safe_json_dumps(value)
127
+ except Exception:
128
+ return str(value)
129
+
130
+ # For dicts and other complex types, serialize to JSON string
131
+ try:
132
+ return safe_json_dumps(value)
133
+ except Exception:
134
+ # If JSON serialization fails, convert to string
135
+ return safe_str_repr(value)
136
+
137
+
138
+ def safe_str_repr(value: Any) -> str:
139
+ """
140
+ Safely convert a value to string representation.
141
+ Handles objects with __repr__ that might raise exceptions.
142
+ Uses AIQA_MAX_OBJECT_STR_CHARS environment variable (default: 100000) to limit length.
143
+ """
144
+ try:
145
+ # Try __repr__ first (usually more informative)
146
+ repr_str = repr(value)
147
+ # Limit length to avoid huge strings
148
+ if len(repr_str) > AIQA_MAX_OBJECT_STR_CHARS:
149
+ return repr_str[:AIQA_MAX_OBJECT_STR_CHARS] + "... (truncated)"
150
+ return repr_str
151
+ except Exception:
152
+ # Fallback to type name
153
+ try:
154
+ return f"<{type(value).__name__} object>"
155
+ except Exception:
156
+ return "<unknown object>"
157
+
158
+
159
+ def object_to_dict(obj: Any, visited: Set[int], max_depth: int = 10, current_depth: int = 0) -> Any:
160
+ """
161
+ Convert an object to a dictionary representation.
162
+
163
+ Args:
164
+ obj: The object to convert
165
+ visited: Set of object IDs to detect circular references
166
+ max_depth: Maximum recursion depth
167
+ current_depth: Current recursion depth
168
+
169
+ Returns:
170
+ Dictionary representation of the object, or a string if conversion fails
171
+ """
172
+ if current_depth > max_depth:
173
+ return "<max depth exceeded>"
174
+
175
+ obj_id = id(obj)
176
+ if obj_id in visited:
177
+ return "<circular reference>"
178
+
179
+ # Handle None
180
+ if obj is None:
181
+ return None
182
+
183
+ # Handle primitives
184
+ if isinstance(obj, (str, int, float, bool, bytes)):
185
+ return obj
186
+
187
+ # Handle datetime objects
188
+ if isinstance(obj, datetime):
189
+ return obj.isoformat()
190
+ if isinstance(obj, date):
191
+ return obj.isoformat()
192
+ if isinstance(obj, time):
193
+ return obj.isoformat()
194
+
195
+ # Handle dict
196
+ if isinstance(obj, dict):
197
+ visited.add(obj_id)
198
+ try:
199
+ result = {}
200
+ for k, v in obj.items():
201
+ try:
202
+ key_str = str(k) if not isinstance(k, (str, int, float, bool)) else k
203
+ filtered_value = _apply_data_filters(key_str, v)
204
+ result[key_str] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
205
+ except Exception as e:
206
+ # If one key-value pair fails, log and use string representation for the value
207
+ key_str = str(k) if not isinstance(k, (str, int, float, bool)) else k
208
+ logger.debug(f"Failed to convert dict value for key '{key_str}': {e}")
209
+ result[key_str] = safe_str_repr(v)
210
+ visited.remove(obj_id)
211
+ return result
212
+ except Exception as e:
213
+ visited.discard(obj_id)
214
+ logger.debug(f"Failed to convert dict to dict: {e}")
215
+ return safe_str_repr(obj)
216
+
217
+ # Handle list/tuple
218
+ if isinstance(obj, (list, tuple)):
219
+ visited.add(obj_id)
220
+ try:
221
+ result = []
222
+ for item in obj:
223
+ try:
224
+ result.append(object_to_dict(item, visited, max_depth, current_depth + 1))
225
+ except Exception as e:
226
+ # If one item fails, log and use its string representation
227
+ logger.debug(f"Failed to convert list item {type(item).__name__} to dict: {e}")
228
+ result.append(safe_str_repr(item))
229
+ visited.remove(obj_id)
230
+ return result
231
+ except Exception as e:
232
+ visited.discard(obj_id)
233
+ logger.debug(f"Failed to convert list/tuple to dict: {e}")
234
+ return safe_str_repr(obj)
235
+
236
+ # Handle dataclasses
237
+ if dataclasses.is_dataclass(obj):
238
+ visited.add(obj_id)
239
+ try:
240
+ result = {}
241
+ for field in dataclasses.fields(obj):
242
+ try:
243
+ value = getattr(obj, field.name, None)
244
+ filtered_value = _apply_data_filters(field.name, value)
245
+ result[field.name] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
246
+ except Exception as e:
247
+ # If accessing a field fails, log and skip it
248
+ logger.debug(f"Failed to access field {field.name} on {type(obj).__name__}: {e}")
249
+ result[field.name] = "<error accessing field>"
250
+ visited.remove(obj_id)
251
+ return result
252
+ except Exception as e:
253
+ visited.discard(obj_id)
254
+ logger.debug(f"Failed to convert dataclass {type(obj).__name__} to dict: {e}")
255
+ return safe_str_repr(obj)
256
+
257
+ # Handle objects with __dict__
258
+ if hasattr(obj, "__dict__"):
259
+ visited.add(obj_id)
260
+ try:
261
+ result = {}
262
+ for key, value in obj.__dict__.items():
263
+ # Skip private attributes that start with __
264
+ if not (isinstance(key, str) and key.startswith("__")):
265
+ filtered_value = _apply_data_filters(key, value)
266
+ result[key] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
267
+ visited.remove(obj_id)
268
+ return result
269
+ except Exception as e:
270
+ visited.discard(obj_id)
271
+ # Log the error for debugging, but still return string representation
272
+ logger.debug(f"Failed to convert object {type(obj).__name__} to dict: {e}")
273
+ return safe_str_repr(obj)
274
+
275
+ # Handle objects with __slots__
276
+ if hasattr(obj, "__slots__"):
277
+ visited.add(obj_id)
278
+ try:
279
+ result = {}
280
+ for slot in obj.__slots__:
281
+ try:
282
+ if hasattr(obj, slot):
283
+ value = getattr(obj, slot, None)
284
+ filtered_value = _apply_data_filters(slot, value)
285
+ result[slot] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
286
+ except Exception as e:
287
+ # If accessing a slot fails, log and skip it
288
+ logger.debug(f"Failed to access slot {slot} on {type(obj).__name__}: {e}")
289
+ result[slot] = "<error accessing slot>"
290
+ visited.remove(obj_id)
291
+ return result
292
+ except Exception as e:
293
+ visited.discard(obj_id)
294
+ logger.debug(f"Failed to convert slotted object {type(obj).__name__} to dict: {e}")
295
+ return safe_str_repr(obj)
296
+
297
+ # Fallback: try to get a few common attributes
298
+ try:
299
+ result = {}
300
+ for attr in ["name", "id", "value", "type", "status"]:
301
+ if hasattr(obj, attr):
302
+ value = getattr(obj, attr, None)
303
+ filtered_value = _apply_data_filters(attr, value)
304
+ result[attr] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
305
+ if result:
306
+ return result
307
+ except Exception:
308
+ pass
309
+
310
+ # Final fallback: string representation
311
+ return safe_str_repr(obj)
312
+
313
+
314
+ def safe_json_dumps(value: Any) -> str:
315
+ """
316
+ Safely serialize a value to JSON string with safeguards against:
317
+ - Circular references
318
+ - Large objects (size limits)
319
+ - Unconvertible parts
320
+
321
+ Args:
322
+ value: The value to serialize
323
+
324
+ Uses AIQA_MAX_OBJECT_STR_CHARS environment variable (default: 1000000) to limit length.
325
+
326
+ Returns:
327
+ JSON string representation
328
+ """
329
+ max_size_chars = AIQA_MAX_OBJECT_STR_CHARS
330
+ visited: Set[int] = set()
331
+
332
+ # Convert the entire structure to ensure circular references are detected
333
+ # across the whole object graph
334
+ try:
335
+ converted = object_to_dict(value, visited)
336
+ except Exception as e:
337
+ # If conversion fails, try with a fresh visited set and json default handler
338
+ logger.debug(f"object_to_dict failed for {type(value).__name__}, trying json.dumps with default handler: {e}")
339
+ try:
340
+ json_str = json.dumps(value, default=json_default_handler_factory(set()))
341
+ if len(json_str) > max_size_chars:
342
+ return f"<object {type(value)} too large: {len(json_str)} chars (limit: {max_size_chars} chars) begins: {json_str[:100]}... conversion error: {e}>"
343
+ return json_str
344
+ except Exception as e2:
345
+ logger.debug(f"json.dumps with default handler also failed for {type(value).__name__}: {e2}")
346
+ return safe_str_repr(value)
347
+
348
+ # Try JSON serialization of the converted structure
349
+ try:
350
+ json_str = json.dumps(converted, default=json_default_handler_factory(set()))
351
+ # Check size
352
+ if len(json_str) > max_size_chars:
353
+ return f"<object {type(value)} too large: {len(json_str)} chars (limit: {max_size_chars} chars) begins: {json_str[:100]}...>"
354
+ return json_str
355
+ except Exception as e:
356
+ logger.debug(f"json.dumps total fail for {type(value).__name__}: {e2}")
357
+ # Final fallback
358
+ return safe_str_repr(value)
359
+
360
+
361
+ def json_default_handler_factory(visited: Set[int]) -> Callable[[Any], Any]:
362
+ """
363
+ Create a JSON default handler with a shared visited set for circular reference detection.
364
+ """
365
+ def handler(obj: Any) -> Any:
366
+ # Handle datetime objects
367
+ if isinstance(obj, datetime):
368
+ return obj.isoformat()
369
+ if isinstance(obj, date):
370
+ return obj.isoformat()
371
+ if isinstance(obj, time):
372
+ return obj.isoformat()
373
+
374
+ # Handle bytes
375
+ if isinstance(obj, bytes):
376
+ try:
377
+ return obj.decode('utf-8')
378
+ except UnicodeDecodeError:
379
+ return f"<bytes: {len(obj)} bytes>"
380
+
381
+ # Try object conversion with the shared visited set
382
+ try:
383
+ return object_to_dict(obj, visited)
384
+ except Exception:
385
+ return safe_str_repr(obj)
386
+
387
+ return handler
388
+
389
+
390
+ def json_default_handler(obj: Any) -> Any:
391
+ """
392
+ Default handler for JSON serialization of non-serializable objects.
393
+ This is a fallback that creates its own visited set.
394
+ """
395
+ return json_default_handler_factory(set())(obj)
396
+
@@ -0,0 +1,176 @@
1
+ """
2
+ Example usage of the ExperimentRunner class.
3
+ """
4
+
5
+ import asyncio
6
+ import os
7
+ from dotenv import load_dotenv
8
+ from aiqa import ExperimentRunner
9
+
10
+ # Load environment variables
11
+ load_dotenv()
12
+
13
+
14
+ # A dummy test engine that returns a dummy response
15
+ async def my_engine(input_data):
16
+ """
17
+ Example engine function that simulates an API call.
18
+ Note: For run(), the engine only takes input_data.
19
+ For run_example(), you can use an engine that takes (input_data, parameters).
20
+ """
21
+ # Imitate an OpenAI API response
22
+ # Sleep for random about 0.5 - 1 seconds
23
+ import random
24
+
25
+ sleep_time = random.random() * 0.5 + 0.5
26
+ await asyncio.sleep(sleep_time)
27
+ return {
28
+ "choices": [
29
+ {
30
+ "message": {
31
+ "content": f"hello {input_data}",
32
+ },
33
+ },
34
+ ],
35
+ }
36
+
37
+
38
+ async def scorer(output, example):
39
+ """
40
+ Example scorer function that scores the output.
41
+ In a real scenario, you would use the metrics from the dataset.
42
+ Note: For run(), the scorer only takes (output, example).
43
+ For run_example(), you can use a scorer that takes (output, example, parameters).
44
+ """
45
+ # This is a simple example - in practice, you'd use the metrics from the dataset
46
+ # and call the scoring functions accordingly
47
+ scores = {}
48
+ # Add your scoring logic here
49
+ return scores
50
+
51
+
52
+ async def example_basic_usage():
53
+ """
54
+ Basic example of using ExperimentRunner.
55
+ """
56
+ if not os.getenv("AIQA_API_KEY"):
57
+ print("Warning: AIQA_API_KEY environment variable is not set. Example may fail.")
58
+
59
+ dataset_id = "your-dataset-id-here"
60
+ organisation_id = "your-organisation-id-here"
61
+
62
+ experiment_runner = ExperimentRunner(
63
+ dataset_id=dataset_id,
64
+ organisation_id=organisation_id,
65
+ )
66
+
67
+ # Get metrics from the dataset
68
+ dataset = experiment_runner.get_dataset()
69
+ metrics = dataset.get("metrics", [])
70
+ print(f"Found {len(metrics)} metrics in dataset: {[m['name'] for m in metrics]}")
71
+
72
+ # Create scorer that scores all metrics from the dataset
73
+ # (In practice, you'd implement this based on your metrics)
74
+ async def dataset_scorer(output, example):
75
+ # Use the metrics from the dataset to score
76
+ # This is a placeholder - implement based on your actual metrics
77
+ return await scorer(output, example)
78
+
79
+ # Get example inputs
80
+ example_inputs = experiment_runner.get_example_inputs()
81
+ print(f"Processing {len(example_inputs)} examples")
82
+
83
+ # Run experiments on each example
84
+ for example in example_inputs:
85
+ result = await experiment_runner.run_example(example, my_engine, dataset_scorer)
86
+ if result and len(result) > 0:
87
+ print(f"Scored example {example['id']}: {result}")
88
+ else:
89
+ print(f"No results for example {example['id']}")
90
+
91
+ # Get summary results
92
+ summary_results = experiment_runner.get_summary_results()
93
+ print(f"Summary results: {summary_results}")
94
+
95
+
96
+ async def example_with_experiment_setup():
97
+ """
98
+ Example of creating an experiment with custom setup.
99
+ """
100
+ dataset_id = "your-dataset-id-here"
101
+ organisation_id = "your-organisation-id-here"
102
+
103
+ experiment_runner = ExperimentRunner(
104
+ dataset_id=dataset_id,
105
+ organisation_id=organisation_id,
106
+ )
107
+
108
+ # Create experiment with custom parameters
109
+ experiment = experiment_runner.create_experiment(
110
+ {
111
+ "name": "My Custom Experiment",
112
+ "parameters": {
113
+ "model": "gpt-4",
114
+ "temperature": 0.7,
115
+ },
116
+ "comparison_parameters": [
117
+ {"temperature": 0.5},
118
+ {"temperature": 0.9},
119
+ ],
120
+ }
121
+ )
122
+
123
+ print(f"Created experiment: {experiment['id']}")
124
+
125
+ # Now run the experiment
126
+ await experiment_runner.run(my_engine, scorer)
127
+
128
+
129
+ async def example_stepwise():
130
+ """
131
+ Example of running experiments step by step (more control).
132
+ """
133
+ dataset_id = "your-dataset-id-here"
134
+ organisation_id = "your-organisation-id-here"
135
+
136
+ experiment_runner = ExperimentRunner(
137
+ dataset_id=dataset_id,
138
+ organisation_id=organisation_id,
139
+ )
140
+
141
+ # Get the dataset
142
+ dataset = experiment_runner.get_dataset()
143
+ metrics = dataset.get("metrics", [])
144
+ print(f"Found {len(metrics)} metrics in dataset")
145
+
146
+ # Create scorer for run_example (takes parameters)
147
+ async def my_scorer(output, example, parameters):
148
+ # Implement your scoring logic here
149
+ # Note: run_example() passes parameters, so this scorer can use them
150
+ return {"score": 0.8} # Placeholder
151
+
152
+ # Get examples
153
+ examples = experiment_runner.get_example_inputs(limit=100)
154
+ print(f"Processing {len(examples)} examples")
155
+
156
+ # Process each example individually
157
+ for example in examples:
158
+ try:
159
+ result = await experiment_runner.run_example(example, my_engine, my_scorer)
160
+ print(f"Example {example['id']} completed: {result}")
161
+ except Exception as e:
162
+ print(f"Example {example['id']} failed: {e}")
163
+
164
+ # Get final summary
165
+ summary = experiment_runner.get_summary_results()
166
+ print(f"Final summary: {summary}")
167
+
168
+
169
+ if __name__ == "__main__":
170
+ # Uncomment the example you want to run:
171
+ # asyncio.run(example_basic_usage())
172
+ # asyncio.run(example_with_experiment_setup())
173
+ # asyncio.run(example_stepwise())
174
+ print("Please uncomment one of the examples above to run it.")
175
+ print("Make sure to set your dataset_id and organisation_id in the example functions.")
176
+