aiqa-client 0.1.1__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.
- aiqa/__init__.py +44 -7
- aiqa/aiqa_exporter.py +219 -60
- aiqa/client.py +170 -0
- aiqa/experiment_runner.py +336 -0
- aiqa/object_serialiser.py +361 -0
- aiqa/test_experiment_runner.py +176 -0
- aiqa/test_tracing.py +230 -0
- aiqa/tracing.py +1102 -161
- {aiqa_client-0.1.1.dist-info → aiqa_client-0.1.2.dist-info}/METADATA +95 -4
- aiqa_client-0.1.2.dist-info/RECORD +14 -0
- aiqa_client-0.1.1.dist-info/RECORD +0 -9
- {aiqa_client-0.1.1.dist-info → aiqa_client-0.1.2.dist-info}/WHEEL +0 -0
- {aiqa_client-0.1.1.dist-info → aiqa_client-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {aiqa_client-0.1.1.dist-info → aiqa_client-0.1.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ExperimentRunner - runs experiments on datasets and scores results
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, List, Optional, Callable, Awaitable, Union
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExperimentRunner:
|
|
12
|
+
"""
|
|
13
|
+
The ExperimentRunner is the main class for running experiments on datasets.
|
|
14
|
+
It can create an experiment, run it, and score the results.
|
|
15
|
+
Handles setting up environment variables and passing parameters to the engine function.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
dataset_id: str,
|
|
21
|
+
experiment_id: Optional[str] = None,
|
|
22
|
+
server_url: Optional[str] = None,
|
|
23
|
+
api_key: Optional[str] = None,
|
|
24
|
+
organisation_id: Optional[str] = None,
|
|
25
|
+
):
|
|
26
|
+
"""
|
|
27
|
+
Initialize the ExperimentRunner.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
dataset_id: ID of the dataset to run experiments on
|
|
31
|
+
experiment_id: Usually unset, and a fresh experiment is created with a random ID
|
|
32
|
+
server_url: URL of the AIQA server (defaults to AIQA_SERVER_URL env var)
|
|
33
|
+
api_key: API key for authentication (defaults to AIQA_API_KEY env var)
|
|
34
|
+
organisation_id: Organisation ID for the experiment
|
|
35
|
+
"""
|
|
36
|
+
self.dataset_id = dataset_id
|
|
37
|
+
self.experiment_id = experiment_id
|
|
38
|
+
self.server_url = (server_url or os.getenv("AIQA_SERVER_URL", "")).rstrip("/")
|
|
39
|
+
self.api_key = api_key or os.getenv("AIQA_API_KEY", "")
|
|
40
|
+
self.organisation = organisation_id
|
|
41
|
+
self.experiment: Optional[Dict[str, Any]] = None
|
|
42
|
+
self.scores: List[Dict[str, Any]] = []
|
|
43
|
+
|
|
44
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
45
|
+
"""Build HTTP headers for API requests."""
|
|
46
|
+
headers = {"Content-Type": "application/json"}
|
|
47
|
+
if self.api_key:
|
|
48
|
+
headers["Authorization"] = f"ApiKey {self.api_key}"
|
|
49
|
+
return headers
|
|
50
|
+
|
|
51
|
+
def get_dataset(self) -> Dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Fetch the dataset to get its metrics.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The dataset object with metrics and other information
|
|
57
|
+
"""
|
|
58
|
+
response = requests.get(
|
|
59
|
+
f"{self.server_url}/dataset/{self.dataset_id}",
|
|
60
|
+
headers=self._get_headers(),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if not response.ok:
|
|
64
|
+
error_text = response.text if hasattr(response, "text") else "Unknown error"
|
|
65
|
+
raise Exception(
|
|
66
|
+
f"Failed to fetch dataset: {response.status_code} {response.reason} - {error_text}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return response.json()
|
|
70
|
+
|
|
71
|
+
def get_example_inputs(self, limit: int = 10000) -> List[Dict[str, Any]]:
|
|
72
|
+
"""
|
|
73
|
+
Fetch example inputs from the dataset.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
limit: Maximum number of examples to fetch (default: 10000)
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
List of example objects
|
|
80
|
+
"""
|
|
81
|
+
params = {
|
|
82
|
+
"dataset_id": self.dataset_id,
|
|
83
|
+
"limit": str(limit),
|
|
84
|
+
}
|
|
85
|
+
if self.organisation:
|
|
86
|
+
params["organisation"] = self.organisation
|
|
87
|
+
|
|
88
|
+
response = requests.get(
|
|
89
|
+
f"{self.server_url}/example",
|
|
90
|
+
params=params,
|
|
91
|
+
headers=self._get_headers(),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if not response.ok:
|
|
95
|
+
error_text = response.text if hasattr(response, "text") else "Unknown error"
|
|
96
|
+
raise Exception(
|
|
97
|
+
f"Failed to fetch example inputs: {response.status_code} {response.reason} - {error_text}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
data = response.json()
|
|
101
|
+
return data.get("hits", [])
|
|
102
|
+
|
|
103
|
+
def create_experiment(
|
|
104
|
+
self, experiment_setup: Optional[Dict[str, Any]] = None
|
|
105
|
+
) -> Dict[str, Any]:
|
|
106
|
+
"""
|
|
107
|
+
Create an experiment if one does not exist.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
experiment_setup: Optional setup for the experiment object. You may wish to set:
|
|
111
|
+
- name (recommended for labelling the experiment)
|
|
112
|
+
- parameters
|
|
113
|
+
- comparison_parameters
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The created experiment object
|
|
117
|
+
"""
|
|
118
|
+
if not self.organisation or not self.dataset_id:
|
|
119
|
+
raise Exception("Organisation and dataset ID are required to create an experiment")
|
|
120
|
+
|
|
121
|
+
if not experiment_setup:
|
|
122
|
+
experiment_setup = {}
|
|
123
|
+
|
|
124
|
+
# Fill in if not set
|
|
125
|
+
experiment_setup = {
|
|
126
|
+
**experiment_setup,
|
|
127
|
+
"organisation": self.organisation,
|
|
128
|
+
"dataset": self.dataset_id,
|
|
129
|
+
"results": [],
|
|
130
|
+
"summary_results": {},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
print("Creating experiment")
|
|
134
|
+
response = requests.post(
|
|
135
|
+
f"{self.server_url}/experiment",
|
|
136
|
+
json=experiment_setup,
|
|
137
|
+
headers=self._get_headers(),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if not response.ok:
|
|
141
|
+
error_text = response.text if hasattr(response, "text") else "Unknown error"
|
|
142
|
+
raise Exception(
|
|
143
|
+
f"Failed to create experiment: {response.status_code} {response.reason} - {error_text}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
experiment = response.json()
|
|
147
|
+
self.experiment_id = experiment["id"]
|
|
148
|
+
self.experiment = experiment
|
|
149
|
+
return experiment
|
|
150
|
+
|
|
151
|
+
def score_and_store(
|
|
152
|
+
self,
|
|
153
|
+
example: Dict[str, Any],
|
|
154
|
+
result: Any,
|
|
155
|
+
scores: Optional[Dict[str, Any]] = None,
|
|
156
|
+
) -> Dict[str, Any]:
|
|
157
|
+
"""
|
|
158
|
+
Ask the server to score an example result. Stores the score for later summary calculation.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
example: The example object
|
|
162
|
+
result: The output from running the engine on the example
|
|
163
|
+
scores: Optional pre-computed scores
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The score result from the server
|
|
167
|
+
"""
|
|
168
|
+
# Do we have an experiment ID? If not, we need to create the experiment first
|
|
169
|
+
if not self.experiment_id:
|
|
170
|
+
self.create_experiment()
|
|
171
|
+
|
|
172
|
+
if scores is None:
|
|
173
|
+
scores = {}
|
|
174
|
+
|
|
175
|
+
print(f"Scoring and storing example: {example['id']}")
|
|
176
|
+
print(f"Scores: {scores}")
|
|
177
|
+
|
|
178
|
+
response = requests.post(
|
|
179
|
+
f"{self.server_url}/experiment/{self.experiment_id}/example/{example['id']}/scoreAndStore",
|
|
180
|
+
json={
|
|
181
|
+
"output": result,
|
|
182
|
+
"traceId": example.get("traceId"),
|
|
183
|
+
"scores": scores,
|
|
184
|
+
},
|
|
185
|
+
headers=self._get_headers(),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not response.ok:
|
|
189
|
+
error_text = response.text if hasattr(response, "text") else "Unknown error"
|
|
190
|
+
raise Exception(
|
|
191
|
+
f"Failed to score and store: {response.status_code} {response.reason} - {error_text}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
json_result = response.json()
|
|
195
|
+
print(f"scoreAndStore response: {json_result}")
|
|
196
|
+
return json_result
|
|
197
|
+
|
|
198
|
+
async def run(
|
|
199
|
+
self,
|
|
200
|
+
engine: Callable[[Any], Union[Any, Awaitable[Any]]],
|
|
201
|
+
scorer: Optional[
|
|
202
|
+
Callable[[Any, Dict[str, Any]], Awaitable[Dict[str, Any]]]
|
|
203
|
+
] = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Run an engine function on all examples and score the results.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
engine: Function that takes input, returns output (can be async)
|
|
210
|
+
scorer: Optional function that scores the output given the example
|
|
211
|
+
"""
|
|
212
|
+
examples = self.get_example_inputs()
|
|
213
|
+
|
|
214
|
+
# Wrap engine to match run_example signature (input, parameters)
|
|
215
|
+
def wrapped_engine(input_data, parameters):
|
|
216
|
+
return engine(input_data)
|
|
217
|
+
|
|
218
|
+
# Wrap scorer to match run_example signature (output, example, parameters)
|
|
219
|
+
async def wrapped_scorer(output, example, parameters):
|
|
220
|
+
if scorer:
|
|
221
|
+
return await scorer(output, example)
|
|
222
|
+
return {}
|
|
223
|
+
|
|
224
|
+
for example in examples:
|
|
225
|
+
scores = await self.run_example(example, wrapped_engine, wrapped_scorer)
|
|
226
|
+
if scores:
|
|
227
|
+
self.scores.append(
|
|
228
|
+
{
|
|
229
|
+
"example": example,
|
|
230
|
+
"result": scores,
|
|
231
|
+
"scores": scores,
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def run_example(
|
|
236
|
+
self,
|
|
237
|
+
example: Dict[str, Any],
|
|
238
|
+
call_my_code: Callable[[Any, Dict[str, Any]], Union[Any, Awaitable[Any]]],
|
|
239
|
+
score_this_output: Optional[
|
|
240
|
+
Callable[[Any, Dict[str, Any], Dict[str, Any]], Awaitable[Dict[str, Any]]]
|
|
241
|
+
] = None,
|
|
242
|
+
) -> List[Dict[str, Any]]:
|
|
243
|
+
"""
|
|
244
|
+
Run the engine on an example with the given parameters (looping over comparison parameters),
|
|
245
|
+
and score the result. Also calls scoreAndStore to store the result in the server.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
example: The example to run
|
|
249
|
+
call_my_code: Function that takes input and parameters, returns output (can be async)
|
|
250
|
+
score_this_output: Optional function that scores the output given the example and parameters
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
One set of scores for each comparison parameter set. If no comparison parameters,
|
|
254
|
+
returns an array of one.
|
|
255
|
+
"""
|
|
256
|
+
# Ensure experiment exists
|
|
257
|
+
if not self.experiment:
|
|
258
|
+
self.create_experiment()
|
|
259
|
+
if not self.experiment:
|
|
260
|
+
raise Exception("Failed to create experiment")
|
|
261
|
+
|
|
262
|
+
# Make the parameters
|
|
263
|
+
parameters_fixed = self.experiment.get("parameters") or {}
|
|
264
|
+
# If comparison_parameters is empty/undefined, default to [{}] so we run at least once
|
|
265
|
+
parameters_loop = self.experiment.get("comparison_parameters") or [{}]
|
|
266
|
+
|
|
267
|
+
# Handle both spans array and input field
|
|
268
|
+
input_data = example.get("input")
|
|
269
|
+
if not input_data and example.get("spans") and len(example["spans"]) > 0:
|
|
270
|
+
input_data = example["spans"][0].get("attributes", {}).get("input")
|
|
271
|
+
|
|
272
|
+
if not input_data:
|
|
273
|
+
print(
|
|
274
|
+
f"Warning: Example has no input field or spans with input attribute: {example}"
|
|
275
|
+
)
|
|
276
|
+
# Run engine anyway -- this could make sense if it's all about the parameters
|
|
277
|
+
|
|
278
|
+
all_scores: List[Dict[str, Any]] = []
|
|
279
|
+
# This loop should not be parallelized - it should run sequentially, one after the other
|
|
280
|
+
# to avoid creating interference between the runs.
|
|
281
|
+
for parameters in parameters_loop:
|
|
282
|
+
parameters_here = {**parameters_fixed, **parameters}
|
|
283
|
+
print(f"Running with parameters: {parameters_here}")
|
|
284
|
+
|
|
285
|
+
# Set env vars from parameters_here
|
|
286
|
+
for key, value in parameters_here.items():
|
|
287
|
+
if value:
|
|
288
|
+
os.environ[key] = str(value)
|
|
289
|
+
|
|
290
|
+
start = time.time() * 1000 # milliseconds
|
|
291
|
+
output = call_my_code(input_data, parameters_here)
|
|
292
|
+
# Handle async functions
|
|
293
|
+
if hasattr(output, "__await__"):
|
|
294
|
+
import asyncio
|
|
295
|
+
|
|
296
|
+
output = await output
|
|
297
|
+
end = time.time() * 1000 # milliseconds
|
|
298
|
+
duration = int(end - start)
|
|
299
|
+
|
|
300
|
+
print(f"Output: {output}")
|
|
301
|
+
|
|
302
|
+
scores: Dict[str, Any] = {}
|
|
303
|
+
if score_this_output:
|
|
304
|
+
scores = await score_this_output(output, example, parameters_here)
|
|
305
|
+
|
|
306
|
+
scores["duration"] = duration
|
|
307
|
+
|
|
308
|
+
# TODO: this call as async and wait for all to complete before returning
|
|
309
|
+
print(f"Call scoreAndStore ... for example: {example['id']} with scores: {scores}")
|
|
310
|
+
result = self.score_and_store(example, output, scores)
|
|
311
|
+
print(f"scoreAndStore returned: {result}")
|
|
312
|
+
all_scores.append(result)
|
|
313
|
+
|
|
314
|
+
return all_scores
|
|
315
|
+
|
|
316
|
+
def get_summary_results(self) -> Dict[str, Any]:
|
|
317
|
+
"""
|
|
318
|
+
Get summary results from the experiment.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Dictionary of metric names to summary statistics
|
|
322
|
+
"""
|
|
323
|
+
response = requests.get(
|
|
324
|
+
f"{self.server_url}/experiment/{self.experiment_id}",
|
|
325
|
+
headers=self._get_headers(),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if not response.ok:
|
|
329
|
+
error_text = response.text if hasattr(response, "text") else "Unknown error"
|
|
330
|
+
raise Exception(
|
|
331
|
+
f"Failed to fetch summary results: {response.status_code} {response.reason} - {error_text}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
experiment2 = response.json()
|
|
335
|
+
return experiment2.get("summary_results", {})
|
|
336
|
+
|
|
@@ -0,0 +1,361 @@
|
|
|
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
|
+
from datetime import datetime, date, time
|
|
10
|
+
from typing import Any, Callable, Set
|
|
11
|
+
|
|
12
|
+
def toNumber(value: str|int|None) -> int:
|
|
13
|
+
"""Convert string to number. handling units like g, m, k, (also mb kb gb though these should be avoided)"""
|
|
14
|
+
if value is None:
|
|
15
|
+
return 0
|
|
16
|
+
if isinstance(value, int):
|
|
17
|
+
return value
|
|
18
|
+
if value.endswith("b"): # drop the b
|
|
19
|
+
value = value[:-1]
|
|
20
|
+
if value.endswith("g"):
|
|
21
|
+
return int(value[:-1]) * 1024 * 1024 * 1024
|
|
22
|
+
elif value.endswith("m"):
|
|
23
|
+
return int(value[:-1]) * 1024 * 1024
|
|
24
|
+
elif value.endswith("k"):
|
|
25
|
+
return int(value[:-1]) * 1024
|
|
26
|
+
return int(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Configurable limit for object string representation (in characters)
|
|
30
|
+
AIQA_MAX_OBJECT_STR_CHARS = toNumber(os.getenv("AIQA_MAX_OBJECT_STR_CHARS", "1m"))
|
|
31
|
+
|
|
32
|
+
# Data filters configuration
|
|
33
|
+
def _get_enabled_filters() -> Set[str]:
|
|
34
|
+
"""Get set of enabled filter names from AIQA_DATA_FILTERS env var."""
|
|
35
|
+
filters_env = os.getenv("AIQA_DATA_FILTERS", "RemovePasswords, RemoveJWT, RemoveAuthHeaders, RemoveAPIKeys")
|
|
36
|
+
if not filters_env or filters_env.lower() == "false":
|
|
37
|
+
return set()
|
|
38
|
+
return {f.strip() for f in filters_env.split(",") if f.strip()}
|
|
39
|
+
|
|
40
|
+
_ENABLED_FILTERS = _get_enabled_filters()
|
|
41
|
+
|
|
42
|
+
def _is_jwt_token(value: Any) -> bool:
|
|
43
|
+
"""Check if a value looks like a JWT token (starts with 'eyJ' and has 3 parts separated by dots)."""
|
|
44
|
+
if not isinstance(value, str):
|
|
45
|
+
return False
|
|
46
|
+
# JWT tokens have format: header.payload.signature (3 parts separated by dots)
|
|
47
|
+
# They typically start with 'eyJ' (base64 encoded '{"')
|
|
48
|
+
parts = value.split('.')
|
|
49
|
+
return len(parts) == 3 and value.startswith('eyJ') and all(len(p) > 0 for p in parts)
|
|
50
|
+
|
|
51
|
+
def _is_api_key(value: Any) -> bool:
|
|
52
|
+
"""Check if a value looks like an API key based on common patterns."""
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
return False
|
|
55
|
+
value = value.strip()
|
|
56
|
+
# Common API key prefixes:
|
|
57
|
+
api_key_prefixes = [
|
|
58
|
+
'sk-', # OpenAI secret key
|
|
59
|
+
'pk-', # possibly public key
|
|
60
|
+
'AKIA', # AWS access key
|
|
61
|
+
'ghp_', # GitHub personal access token
|
|
62
|
+
'gho_', # GitHub OAuth token
|
|
63
|
+
'ghu_', # GitHub unidentified token
|
|
64
|
+
'ghs_', # GitHub SAML token
|
|
65
|
+
'ghr_' # GitHub refresh token
|
|
66
|
+
]
|
|
67
|
+
return any(value.startswith(prefix) for prefix in api_key_prefixes)
|
|
68
|
+
|
|
69
|
+
def _apply_data_filters(key: str, value: Any) -> Any:
|
|
70
|
+
"""Apply data filters to a key-value pair based on enabled filters."""
|
|
71
|
+
if not value: # Don't filter falsy values
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
key_lower = str(key).lower()
|
|
75
|
+
|
|
76
|
+
# RemovePasswords filter: if key contains "password", replace value with "****"
|
|
77
|
+
if "RemovePasswords" in _ENABLED_FILTERS and "password" in key_lower:
|
|
78
|
+
return "****"
|
|
79
|
+
|
|
80
|
+
# RemoveJWT filter: if value looks like a JWT token, replace with "****"
|
|
81
|
+
if "RemoveJWT" in _ENABLED_FILTERS and _is_jwt_token(value):
|
|
82
|
+
return "****"
|
|
83
|
+
|
|
84
|
+
# RemoveAuthHeaders filter: if key is "authorization" (case-insensitive), replace value with "****"
|
|
85
|
+
if "RemoveAuthHeaders" in _ENABLED_FILTERS and key_lower == "authorization":
|
|
86
|
+
return "****"
|
|
87
|
+
|
|
88
|
+
# RemoveAPIKeys filter: if key contains API key patterns or value looks like an API key
|
|
89
|
+
if "RemoveAPIKeys" in _ENABLED_FILTERS:
|
|
90
|
+
# Check key patterns
|
|
91
|
+
api_key_key_patterns = ['api_key', 'apikey', 'api-key', 'apikey']
|
|
92
|
+
if any(pattern in key_lower for pattern in api_key_key_patterns):
|
|
93
|
+
return "****"
|
|
94
|
+
# Check value patterns
|
|
95
|
+
if _is_api_key(value):
|
|
96
|
+
return "****"
|
|
97
|
+
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def serialize_for_span(value: Any) -> Any:
|
|
102
|
+
"""
|
|
103
|
+
Serialize a value for span attributes.
|
|
104
|
+
OpenTelemetry only accepts primitives (bool, str, bytes, int, float) or sequences of those.
|
|
105
|
+
Complex types (dicts, lists, objects) are converted to JSON strings.
|
|
106
|
+
|
|
107
|
+
Handles objects by attempting to convert them to dicts, with safeguards against:
|
|
108
|
+
- Circular references
|
|
109
|
+
- Unconvertible parts
|
|
110
|
+
- Large objects (size limits)
|
|
111
|
+
"""
|
|
112
|
+
# Keep primitives as is (including None)
|
|
113
|
+
if value is None or isinstance(value, (str, int, float, bool, bytes)):
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
# For sequences, check if all elements are primitives
|
|
117
|
+
if isinstance(value, (list, tuple)):
|
|
118
|
+
# If all elements are primitives, return as list
|
|
119
|
+
if all(isinstance(item, (str, int, float, bool, bytes, type(None))) for item in value):
|
|
120
|
+
return list(value)
|
|
121
|
+
# Otherwise serialize to JSON string
|
|
122
|
+
try:
|
|
123
|
+
return safe_json_dumps(value)
|
|
124
|
+
except Exception:
|
|
125
|
+
return str(value)
|
|
126
|
+
|
|
127
|
+
# For dicts and other complex types, serialize to JSON string
|
|
128
|
+
try:
|
|
129
|
+
return safe_json_dumps(value)
|
|
130
|
+
except Exception:
|
|
131
|
+
# If JSON serialization fails, convert to string
|
|
132
|
+
return safe_str_repr(value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def safe_str_repr(value: Any) -> str:
|
|
136
|
+
"""
|
|
137
|
+
Safely convert a value to string representation.
|
|
138
|
+
Handles objects with __repr__ that might raise exceptions.
|
|
139
|
+
Uses AIQA_MAX_OBJECT_STR_CHARS environment variable (default: 100000) to limit length.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
# Try __repr__ first (usually more informative)
|
|
143
|
+
repr_str = repr(value)
|
|
144
|
+
# Limit length to avoid huge strings
|
|
145
|
+
if len(repr_str) > AIQA_MAX_OBJECT_STR_CHARS:
|
|
146
|
+
return repr_str[:AIQA_MAX_OBJECT_STR_CHARS] + "... (truncated)"
|
|
147
|
+
return repr_str
|
|
148
|
+
except Exception:
|
|
149
|
+
# Fallback to type name
|
|
150
|
+
try:
|
|
151
|
+
return f"<{type(value).__name__} object>"
|
|
152
|
+
except Exception:
|
|
153
|
+
return "<unknown object>"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def object_to_dict(obj: Any, visited: Set[int], max_depth: int = 10, current_depth: int = 0) -> Any:
|
|
157
|
+
"""
|
|
158
|
+
Convert an object to a dictionary representation.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
obj: The object to convert
|
|
162
|
+
visited: Set of object IDs to detect circular references
|
|
163
|
+
max_depth: Maximum recursion depth
|
|
164
|
+
current_depth: Current recursion depth
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Dictionary representation of the object, or a string if conversion fails
|
|
168
|
+
"""
|
|
169
|
+
if current_depth > max_depth:
|
|
170
|
+
return "<max depth exceeded>"
|
|
171
|
+
|
|
172
|
+
obj_id = id(obj)
|
|
173
|
+
if obj_id in visited:
|
|
174
|
+
return "<circular reference>"
|
|
175
|
+
|
|
176
|
+
# Handle None
|
|
177
|
+
if obj is None:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
# Handle primitives
|
|
181
|
+
if isinstance(obj, (str, int, float, bool, bytes)):
|
|
182
|
+
return obj
|
|
183
|
+
|
|
184
|
+
# Handle datetime objects
|
|
185
|
+
if isinstance(obj, datetime):
|
|
186
|
+
return obj.isoformat()
|
|
187
|
+
if isinstance(obj, date):
|
|
188
|
+
return obj.isoformat()
|
|
189
|
+
if isinstance(obj, time):
|
|
190
|
+
return obj.isoformat()
|
|
191
|
+
|
|
192
|
+
# Handle dict
|
|
193
|
+
if isinstance(obj, dict):
|
|
194
|
+
visited.add(obj_id)
|
|
195
|
+
try:
|
|
196
|
+
result = {}
|
|
197
|
+
for k, v in obj.items():
|
|
198
|
+
key_str = str(k) if not isinstance(k, (str, int, float, bool)) else k
|
|
199
|
+
filtered_value = _apply_data_filters(key_str, v)
|
|
200
|
+
result[key_str] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
|
|
201
|
+
visited.remove(obj_id)
|
|
202
|
+
return result
|
|
203
|
+
except Exception:
|
|
204
|
+
visited.discard(obj_id)
|
|
205
|
+
return safe_str_repr(obj)
|
|
206
|
+
|
|
207
|
+
# Handle list/tuple
|
|
208
|
+
if isinstance(obj, (list, tuple)):
|
|
209
|
+
visited.add(obj_id)
|
|
210
|
+
try:
|
|
211
|
+
result = [object_to_dict(item, visited, max_depth, current_depth + 1) for item in obj]
|
|
212
|
+
visited.remove(obj_id)
|
|
213
|
+
return result
|
|
214
|
+
except Exception:
|
|
215
|
+
visited.discard(obj_id)
|
|
216
|
+
return safe_str_repr(obj)
|
|
217
|
+
|
|
218
|
+
# Handle dataclasses
|
|
219
|
+
if dataclasses.is_dataclass(obj):
|
|
220
|
+
visited.add(obj_id)
|
|
221
|
+
try:
|
|
222
|
+
result = {}
|
|
223
|
+
for field in dataclasses.fields(obj):
|
|
224
|
+
value = getattr(obj, field.name, None)
|
|
225
|
+
filtered_value = _apply_data_filters(field.name, value)
|
|
226
|
+
result[field.name] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
|
|
227
|
+
visited.remove(obj_id)
|
|
228
|
+
return result
|
|
229
|
+
except Exception:
|
|
230
|
+
visited.discard(obj_id)
|
|
231
|
+
return safe_str_repr(obj)
|
|
232
|
+
|
|
233
|
+
# Handle objects with __dict__
|
|
234
|
+
if hasattr(obj, "__dict__"):
|
|
235
|
+
visited.add(obj_id)
|
|
236
|
+
try:
|
|
237
|
+
result = {}
|
|
238
|
+
for key, value in obj.__dict__.items():
|
|
239
|
+
# Skip private attributes that start with __
|
|
240
|
+
if not (isinstance(key, str) and key.startswith("__")):
|
|
241
|
+
filtered_value = _apply_data_filters(key, value)
|
|
242
|
+
result[key] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
|
|
243
|
+
visited.remove(obj_id)
|
|
244
|
+
return result
|
|
245
|
+
except Exception:
|
|
246
|
+
visited.discard(obj_id)
|
|
247
|
+
return safe_str_repr(obj)
|
|
248
|
+
|
|
249
|
+
# Handle objects with __slots__
|
|
250
|
+
if hasattr(obj, "__slots__"):
|
|
251
|
+
visited.add(obj_id)
|
|
252
|
+
try:
|
|
253
|
+
result = {}
|
|
254
|
+
for slot in obj.__slots__:
|
|
255
|
+
if hasattr(obj, slot):
|
|
256
|
+
value = getattr(obj, slot, None)
|
|
257
|
+
filtered_value = _apply_data_filters(slot, value)
|
|
258
|
+
result[slot] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
|
|
259
|
+
visited.remove(obj_id)
|
|
260
|
+
return result
|
|
261
|
+
except Exception:
|
|
262
|
+
visited.discard(obj_id)
|
|
263
|
+
return safe_str_repr(obj)
|
|
264
|
+
|
|
265
|
+
# Fallback: try to get a few common attributes
|
|
266
|
+
try:
|
|
267
|
+
result = {}
|
|
268
|
+
for attr in ["name", "id", "value", "type", "status"]:
|
|
269
|
+
if hasattr(obj, attr):
|
|
270
|
+
value = getattr(obj, attr, None)
|
|
271
|
+
filtered_value = _apply_data_filters(attr, value)
|
|
272
|
+
result[attr] = object_to_dict(filtered_value, visited, max_depth, current_depth + 1)
|
|
273
|
+
if result:
|
|
274
|
+
return result
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
# Final fallback: string representation
|
|
279
|
+
return safe_str_repr(obj)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def safe_json_dumps(value: Any) -> str:
|
|
283
|
+
"""
|
|
284
|
+
Safely serialize a value to JSON string with safeguards against:
|
|
285
|
+
- Circular references
|
|
286
|
+
- Large objects (size limits)
|
|
287
|
+
- Unconvertible parts
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
value: The value to serialize
|
|
291
|
+
|
|
292
|
+
Uses AIQA_MAX_OBJECT_STR_CHARS environment variable (default: 1000000) to limit length.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
JSON string representation
|
|
296
|
+
"""
|
|
297
|
+
max_size_chars = AIQA_MAX_OBJECT_STR_CHARS
|
|
298
|
+
visited: Set[int] = set()
|
|
299
|
+
|
|
300
|
+
# Convert the entire structure to ensure circular references are detected
|
|
301
|
+
# across the whole object graph
|
|
302
|
+
try:
|
|
303
|
+
converted = object_to_dict(value, visited)
|
|
304
|
+
except Exception:
|
|
305
|
+
# If conversion fails, try with a fresh visited set and json default handler
|
|
306
|
+
try:
|
|
307
|
+
json_str = json.dumps(value, default=json_default_handler_factory(set()))
|
|
308
|
+
if len(json_str) > max_size_chars:
|
|
309
|
+
return f"<object {type(value)} too large: {len(json_str)} chars (limit: {max_size_chars} chars) begins: {json_str[:100]}... conversion error: {e}>"
|
|
310
|
+
return json_str
|
|
311
|
+
except Exception:
|
|
312
|
+
return safe_str_repr(value)
|
|
313
|
+
|
|
314
|
+
# Try JSON serialization of the converted structure
|
|
315
|
+
try:
|
|
316
|
+
json_str = json.dumps(converted, default=json_default_handler_factory(set()))
|
|
317
|
+
# Check size
|
|
318
|
+
if len(json_str) > max_size_chars:
|
|
319
|
+
return f"<object {type(value)} too large: {len(json_str)} chars (limit: {max_size_chars} chars) begins: {json_str[:100]}...>"
|
|
320
|
+
return json_str
|
|
321
|
+
except Exception:
|
|
322
|
+
# Final fallback
|
|
323
|
+
return safe_str_repr(value)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def json_default_handler_factory(visited: Set[int]) -> Callable[[Any], Any]:
|
|
327
|
+
"""
|
|
328
|
+
Create a JSON default handler with a shared visited set for circular reference detection.
|
|
329
|
+
"""
|
|
330
|
+
def handler(obj: Any) -> Any:
|
|
331
|
+
# Handle datetime objects
|
|
332
|
+
if isinstance(obj, datetime):
|
|
333
|
+
return obj.isoformat()
|
|
334
|
+
if isinstance(obj, date):
|
|
335
|
+
return obj.isoformat()
|
|
336
|
+
if isinstance(obj, time):
|
|
337
|
+
return obj.isoformat()
|
|
338
|
+
|
|
339
|
+
# Handle bytes
|
|
340
|
+
if isinstance(obj, bytes):
|
|
341
|
+
try:
|
|
342
|
+
return obj.decode('utf-8')
|
|
343
|
+
except UnicodeDecodeError:
|
|
344
|
+
return f"<bytes: {len(obj)} bytes>"
|
|
345
|
+
|
|
346
|
+
# Try object conversion with the shared visited set
|
|
347
|
+
try:
|
|
348
|
+
return object_to_dict(obj, visited)
|
|
349
|
+
except Exception:
|
|
350
|
+
return safe_str_repr(obj)
|
|
351
|
+
|
|
352
|
+
return handler
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def json_default_handler(obj: Any) -> Any:
|
|
356
|
+
"""
|
|
357
|
+
Default handler for JSON serialization of non-serializable objects.
|
|
358
|
+
This is a fallback that creates its own visited set.
|
|
359
|
+
"""
|
|
360
|
+
return json_default_handler_factory(set())(obj)
|
|
361
|
+
|