google-cloud-agentplatform 1.165.1.dev0__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.
- agentplatform/__init__.py +72 -0
- agentplatform/_genai/__init__.py +43 -0
- agentplatform/_genai/_agent_engines_utils.py +2341 -0
- agentplatform/_genai/_bigquery_utils.py +49 -0
- agentplatform/_genai/_datasets_utils.py +344 -0
- agentplatform/_genai/_evals_builtin_tools.py +209 -0
- agentplatform/_genai/_evals_common.py +4268 -0
- agentplatform/_genai/_evals_constant.py +122 -0
- agentplatform/_genai/_evals_data_converters.py +926 -0
- agentplatform/_genai/_evals_metric_handlers.py +1783 -0
- agentplatform/_genai/_evals_metric_loaders.py +401 -0
- agentplatform/_genai/_evals_utils.py +1043 -0
- agentplatform/_genai/_evals_visualization.py +2070 -0
- agentplatform/_genai/_gcs_utils.py +262 -0
- agentplatform/_genai/_logging_utils.py +47 -0
- agentplatform/_genai/_memory_bank_utils.py +206 -0
- agentplatform/_genai/_observability_data_converter.py +186 -0
- agentplatform/_genai/_operations_utils.py +94 -0
- agentplatform/_genai/_prompt_management_utils.py +147 -0
- agentplatform/_genai/_prompt_optimizer_utils.py +215 -0
- agentplatform/_genai/_skills_utils.py +69 -0
- agentplatform/_genai/_transformers.py +628 -0
- agentplatform/_genai/a2a_task_events.py +509 -0
- agentplatform/_genai/a2a_tasks.py +861 -0
- agentplatform/_genai/agent_engines.py +3931 -0
- agentplatform/_genai/client.py +519 -0
- agentplatform/_genai/datasets.py +3045 -0
- agentplatform/_genai/endpoints.py +1149 -0
- agentplatform/_genai/evals.py +6883 -0
- agentplatform/_genai/example_stores.py +1445 -0
- agentplatform/_genai/feedback_contexts.py +700 -0
- agentplatform/_genai/feedback_entries.py +1644 -0
- agentplatform/_genai/live.py +64 -0
- agentplatform/_genai/live_agent_engines.py +179 -0
- agentplatform/_genai/memories.py +2962 -0
- agentplatform/_genai/memory_banks.py +1927 -0
- agentplatform/_genai/memory_revisions.py +465 -0
- agentplatform/_genai/model_garden.py +2638 -0
- agentplatform/_genai/prompt_optimizer.py +995 -0
- agentplatform/_genai/prompts.py +4515 -0
- agentplatform/_genai/rag.py +4961 -0
- agentplatform/_genai/runtime_revisions.py +1257 -0
- agentplatform/_genai/runtimes.py +78 -0
- agentplatform/_genai/sandbox_snapshots.py +1015 -0
- agentplatform/_genai/sandbox_templates.py +1088 -0
- agentplatform/_genai/sandboxes.py +1604 -0
- agentplatform/_genai/session_events.py +543 -0
- agentplatform/_genai/sessions.py +1449 -0
- agentplatform/_genai/skill_revisions.py +377 -0
- agentplatform/_genai/skills.py +1708 -0
- agentplatform/_genai/types/__init__.py +4695 -0
- agentplatform/_genai/types/agent_engines.py +16 -0
- agentplatform/_genai/types/common.py +32784 -0
- agentplatform/_genai/types/evals.py +1031 -0
- agentplatform/_genai/types/prompt_optimizer.py +107 -0
- agentplatform/_genai/types/prompts.py +107 -0
- agentplatform/version.py +17 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/METADATA +79 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/RECORD +62 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/WHEEL +5 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/licenses/LICENSE +202 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
# Copyright 2025 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
"""Utility functions for evals."""
|
|
16
|
+
|
|
17
|
+
import abc
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any, Optional, Union
|
|
25
|
+
|
|
26
|
+
from google.genai._api_client import BaseApiClient
|
|
27
|
+
from google.genai._common import get_value_by_path as getv
|
|
28
|
+
from google.genai._common import set_value_by_path as setv
|
|
29
|
+
import pandas as pd
|
|
30
|
+
|
|
31
|
+
from . import _bigquery_utils
|
|
32
|
+
from . import _gcs_utils
|
|
33
|
+
from . import _transformers
|
|
34
|
+
from . import types
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
GCS_PREFIX = "gs://"
|
|
41
|
+
BQ_PREFIX = "bq://"
|
|
42
|
+
_DEFAULT_EVAL_SERVICE_QPS = 10
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimiter:
|
|
46
|
+
"""Helper class for rate-limiting requests to Vertex AI to improve QoS.
|
|
47
|
+
|
|
48
|
+
Implements a token bucket algorithm to limit the rate at which API calls
|
|
49
|
+
can occur. Designed for cases where the batch size is always 1 for traffic
|
|
50
|
+
shaping and rate limiting.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
seconds_per_event: The time interval (in seconds) between events to
|
|
54
|
+
maintain the desired rate.
|
|
55
|
+
last: The timestamp of the last event.
|
|
56
|
+
_lock: A lock to ensure thread safety.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, rate: float) -> None:
|
|
60
|
+
"""Initializes the rate limiter.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
rate: The number of queries allowed per second.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If the rate is not positive.
|
|
67
|
+
"""
|
|
68
|
+
if not rate or rate <= 0:
|
|
69
|
+
raise ValueError("Rate must be a positive number")
|
|
70
|
+
self.seconds_per_event = 1.0 / rate
|
|
71
|
+
self._next_allowed = time.monotonic()
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
|
|
74
|
+
def sleep_and_advance(self) -> None:
|
|
75
|
+
"""Blocks the current thread until the next event can be admitted.
|
|
76
|
+
|
|
77
|
+
The lock is held only long enough to reserve a time slot. The
|
|
78
|
+
actual sleep happens outside the lock so that multiple threads
|
|
79
|
+
can be sleeping concurrently with staggered wake-up times.
|
|
80
|
+
"""
|
|
81
|
+
with self._lock:
|
|
82
|
+
now = time.monotonic()
|
|
83
|
+
wait_until = max(now, self._next_allowed)
|
|
84
|
+
delay = wait_until - now
|
|
85
|
+
self._next_allowed = wait_until + self.seconds_per_event
|
|
86
|
+
|
|
87
|
+
if delay > 0:
|
|
88
|
+
time.sleep(delay)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class EvalDatasetLoader:
|
|
92
|
+
"""A loader for datasets from various sources, using a shared client."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, api_client: BaseApiClient) -> None:
|
|
95
|
+
self.api_client = api_client
|
|
96
|
+
self.gcs_utils = _gcs_utils.GcsUtils(self.api_client)
|
|
97
|
+
self.bigquery_utils = _bigquery_utils.BigQueryUtils(self.api_client)
|
|
98
|
+
|
|
99
|
+
def _load_file(
|
|
100
|
+
self, filepath: str, file_type: str
|
|
101
|
+
) -> Union[list[dict[str, Any]], Any]:
|
|
102
|
+
"""Loads data from a file into a list of dictionaries."""
|
|
103
|
+
if filepath.startswith(GCS_PREFIX):
|
|
104
|
+
df = self.gcs_utils.read_gcs_file_to_dataframe(filepath, file_type)
|
|
105
|
+
return df.to_dict(orient="records")
|
|
106
|
+
else:
|
|
107
|
+
if file_type == "jsonl":
|
|
108
|
+
df = pd.read_json(filepath, lines=True)
|
|
109
|
+
return df.to_dict(orient="records")
|
|
110
|
+
elif file_type == "csv":
|
|
111
|
+
df = pd.read_csv(filepath, encoding="utf-8")
|
|
112
|
+
return df.to_dict(orient="records")
|
|
113
|
+
else:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Unsupported file type: '{file_type}'. Please provide 'jsonl' or"
|
|
116
|
+
" 'csv'."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def load(
|
|
120
|
+
self, source: Union[str, "pd.DataFrame"]
|
|
121
|
+
) -> Union[list[dict[str, Any]], Any]:
|
|
122
|
+
"""Loads dataset from various sources into a list of dictionaries."""
|
|
123
|
+
if isinstance(source, pd.DataFrame):
|
|
124
|
+
return source.to_dict(orient="records")
|
|
125
|
+
elif isinstance(source, str):
|
|
126
|
+
if source.startswith(BQ_PREFIX):
|
|
127
|
+
df = self.bigquery_utils.load_bigquery_to_dataframe(
|
|
128
|
+
source[len(BQ_PREFIX) :]
|
|
129
|
+
)
|
|
130
|
+
return df.to_dict(orient="records")
|
|
131
|
+
|
|
132
|
+
_, extension = os.path.splitext(source)
|
|
133
|
+
file_type = extension.lower()[1:]
|
|
134
|
+
|
|
135
|
+
if file_type == "jsonl":
|
|
136
|
+
return self._load_file(source, "jsonl")
|
|
137
|
+
elif file_type == "csv":
|
|
138
|
+
return self._load_file(source, "csv")
|
|
139
|
+
else:
|
|
140
|
+
raise TypeError(
|
|
141
|
+
f"Unsupported file type: {file_type} from {source}. Please"
|
|
142
|
+
" provide a valid GCS path with `jsonl` or `csv` suffix, "
|
|
143
|
+
"a local file path, or a valid BigQuery table URI."
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
raise TypeError(
|
|
147
|
+
"Unsupported dataset type. Must be a `pd.DataFrame`, Python"
|
|
148
|
+
" a valid GCS path with `jsonl` or `csv` suffix, a local"
|
|
149
|
+
" file path, or a valid BigQuery table URI."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BatchEvaluateRequestPreparer:
|
|
154
|
+
"""Prepares data for requests."""
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _EvaluationDataset_to_vertex(
|
|
158
|
+
from_object: Union[dict[str, Any], object],
|
|
159
|
+
parent_object: Optional[dict[str, Any]] = None,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
to_object: dict[str, Any] = {}
|
|
162
|
+
|
|
163
|
+
if getv(from_object, ["gcs_source"]) is not None:
|
|
164
|
+
setv(
|
|
165
|
+
to_object,
|
|
166
|
+
["gcs_source"],
|
|
167
|
+
getv(from_object, ["gcs_source"]),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if getv(from_object, ["bigquery_source"]) is not None:
|
|
171
|
+
setv(
|
|
172
|
+
to_object,
|
|
173
|
+
["bigquery_source"],
|
|
174
|
+
getv(from_object, ["bigquery_source"]),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return to_object
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _Metric_to_vertex(
|
|
181
|
+
from_object: Union[dict[str, Any], object],
|
|
182
|
+
parent_object: Optional[dict[str, Any]] = None,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
to_object: dict[str, Any] = {}
|
|
185
|
+
|
|
186
|
+
if getv(from_object, ["prompt_template"]) is not None:
|
|
187
|
+
setv(
|
|
188
|
+
to_object,
|
|
189
|
+
["pointwise_metric_spec", "prompt_template"],
|
|
190
|
+
getv(from_object, ["prompt_template"]),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if getv(from_object, ["judge_model"]) is not None:
|
|
194
|
+
setv(
|
|
195
|
+
parent_object,
|
|
196
|
+
["autorater_config", "autorater_model"],
|
|
197
|
+
getv(from_object, ["judge_model"]),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if getv(from_object, ["judge_model_sampling_count"]) is not None:
|
|
201
|
+
setv(
|
|
202
|
+
parent_object,
|
|
203
|
+
["autorater_config", "sampling_count"],
|
|
204
|
+
getv(from_object, ["judge_model_sampling_count"]),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if getv(from_object, ["judge_model_system_instruction"]) is not None:
|
|
208
|
+
setv(
|
|
209
|
+
to_object,
|
|
210
|
+
["pointwise_metric_spec", "system_instruction"],
|
|
211
|
+
getv(from_object, ["judge_model_system_instruction"]),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if getv(from_object, ["return_raw_output"]) is not None:
|
|
215
|
+
setv(
|
|
216
|
+
to_object,
|
|
217
|
+
[
|
|
218
|
+
"pointwise_metric_spec",
|
|
219
|
+
"custom_output_format_config",
|
|
220
|
+
"return_raw_output",
|
|
221
|
+
],
|
|
222
|
+
getv(from_object, ["return_raw_output"]),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return to_object
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _OutputConfig_to_vertex(
|
|
229
|
+
from_object: Union[dict[str, Any], object],
|
|
230
|
+
parent_object: Optional[dict[str, Any]] = None,
|
|
231
|
+
) -> dict[str, Any]:
|
|
232
|
+
to_object: dict[str, Any] = {}
|
|
233
|
+
if getv(from_object, ["gcs_destination"]) is not None:
|
|
234
|
+
setv(
|
|
235
|
+
to_object,
|
|
236
|
+
["gcsDestination"],
|
|
237
|
+
getv(from_object, ["gcs_destination"]),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return to_object
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _EvaluationDataset_from_vertex(
|
|
244
|
+
from_object: Union[dict[str, Any], object],
|
|
245
|
+
parent_object: Optional[dict[str, Any]] = None,
|
|
246
|
+
) -> dict[str, Any]:
|
|
247
|
+
to_object: dict[str, Any] = {}
|
|
248
|
+
|
|
249
|
+
if getv(from_object, ["dataset", "gcs_source"]) is not None:
|
|
250
|
+
setv(
|
|
251
|
+
to_object,
|
|
252
|
+
["gcs_source"],
|
|
253
|
+
getv(from_object, ["dataset", "gcs_source"]),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if getv(from_object, ["dataset", "bigquery_source"]) is not None:
|
|
257
|
+
setv(
|
|
258
|
+
to_object,
|
|
259
|
+
["bigquery_source"],
|
|
260
|
+
getv(from_object, ["dataset", "bigquery_source"]),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return to_object
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _AutoraterConfig_to_vertex(
|
|
267
|
+
from_object: Union[dict[str, Any], object],
|
|
268
|
+
parent_object: Optional[dict[str, Any]] = None,
|
|
269
|
+
) -> dict[str, Any]:
|
|
270
|
+
to_object: dict[str, Any] = {}
|
|
271
|
+
if getv(from_object, ["sampling_count"]) is not None:
|
|
272
|
+
setv(to_object, ["samplingCount"], getv(from_object, ["sampling_count"]))
|
|
273
|
+
|
|
274
|
+
if getv(from_object, ["flip_enabled"]) is not None:
|
|
275
|
+
setv(to_object, ["flipEnabled"], getv(from_object, ["flip_enabled"]))
|
|
276
|
+
|
|
277
|
+
if getv(from_object, ["autorater_model"]) is not None:
|
|
278
|
+
setv(
|
|
279
|
+
to_object,
|
|
280
|
+
["autoraterModel"],
|
|
281
|
+
getv(from_object, ["autorater_model"]),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return to_object
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def EvaluateDatasetOperation_from_vertex(
|
|
288
|
+
from_object: Union[dict[str, Any], object],
|
|
289
|
+
parent_object: Optional[dict[str, Any]] = None,
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
to_object: dict[str, Any] = {}
|
|
292
|
+
if getv(from_object, ["name"]) is not None:
|
|
293
|
+
setv(to_object, ["name"], getv(from_object, ["name"]))
|
|
294
|
+
|
|
295
|
+
if getv(from_object, ["metadata"]) is not None:
|
|
296
|
+
setv(to_object, ["metadata"], getv(from_object, ["metadata"]))
|
|
297
|
+
|
|
298
|
+
if getv(from_object, ["done"]) is not None:
|
|
299
|
+
setv(to_object, ["done"], getv(from_object, ["done"]))
|
|
300
|
+
|
|
301
|
+
if getv(from_object, ["error"]) is not None:
|
|
302
|
+
setv(to_object, ["error"], getv(from_object, ["error"]))
|
|
303
|
+
|
|
304
|
+
if getv(from_object, ["response"]) is not None:
|
|
305
|
+
setv(
|
|
306
|
+
to_object,
|
|
307
|
+
["response"],
|
|
308
|
+
BatchEvaluateRequestPreparer._EvaluationDataset_from_vertex(
|
|
309
|
+
getv(from_object, ["response"]), to_object
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return to_object
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def EvaluateDatasetRequestParameters_to_vertex(
|
|
317
|
+
from_object: Union[dict[str, Any], object],
|
|
318
|
+
parent_object: Optional[dict[str, Any]] = None,
|
|
319
|
+
) -> dict[str, Any]:
|
|
320
|
+
to_object: dict[str, Any] = {}
|
|
321
|
+
if getv(from_object, ["dataset"]) is not None:
|
|
322
|
+
setv(
|
|
323
|
+
to_object,
|
|
324
|
+
["dataset"],
|
|
325
|
+
BatchEvaluateRequestPreparer._EvaluationDataset_to_vertex(
|
|
326
|
+
getv(from_object, ["dataset"]), to_object
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
if getv(from_object, ["metrics"]) is not None:
|
|
331
|
+
setv(
|
|
332
|
+
to_object,
|
|
333
|
+
["metrics"],
|
|
334
|
+
[
|
|
335
|
+
BatchEvaluateRequestPreparer._Metric_to_vertex(item, to_object)
|
|
336
|
+
for item in getv(from_object, ["metrics"])
|
|
337
|
+
],
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if getv(from_object, ["output_config"]) is not None:
|
|
341
|
+
setv(
|
|
342
|
+
to_object,
|
|
343
|
+
["outputConfig"],
|
|
344
|
+
BatchEvaluateRequestPreparer._OutputConfig_to_vertex(
|
|
345
|
+
getv(from_object, ["output_config"]), to_object
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if getv(from_object, ["autorater_config"]) is not None:
|
|
350
|
+
setv(
|
|
351
|
+
to_object,
|
|
352
|
+
["autoraterConfig"],
|
|
353
|
+
BatchEvaluateRequestPreparer._AutoraterConfig_to_vertex(
|
|
354
|
+
getv(from_object, ["autorater_config"]), to_object
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if getv(from_object, ["config"]) is not None:
|
|
359
|
+
setv(to_object, ["config"], getv(from_object, ["config"]))
|
|
360
|
+
|
|
361
|
+
return to_object
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def prepare_metric_payload(
|
|
365
|
+
request_dict: dict[str, Any], resolved_metrics: list["types.MetricSubclass"]
|
|
366
|
+
) -> dict[str, Any]:
|
|
367
|
+
"""Prepares the metric payload for the evaluation request.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
request_dict: The dictionary containing the request details.
|
|
371
|
+
resolved_metrics: A list of resolved metric objects.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
The updated request dictionary with the prepared metric payload.
|
|
375
|
+
"""
|
|
376
|
+
request_dict["metrics"] = _transformers.t_metrics(
|
|
377
|
+
resolved_metrics, set_default_aggregation_metrics=True
|
|
378
|
+
)
|
|
379
|
+
return request_dict
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class EvalDataConverter(abc.ABC):
|
|
383
|
+
"""Abstract base class for dataset converters."""
|
|
384
|
+
|
|
385
|
+
@abc.abstractmethod
|
|
386
|
+
def convert(self, raw_data: Any) -> "types.EvaluationDataset":
|
|
387
|
+
"""Converts a loaded raw dataset into an EvaluationDataset."""
|
|
388
|
+
raise NotImplementedError()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _postprocess_user_scenarios_response(
|
|
392
|
+
response: types.GenerateUserScenariosResponse,
|
|
393
|
+
) -> types.EvaluationDataset:
|
|
394
|
+
"""Postprocesses the response from generating user scenarios."""
|
|
395
|
+
eval_cases = []
|
|
396
|
+
data_for_df = []
|
|
397
|
+
if hasattr(response, "user_scenarios") and response.user_scenarios:
|
|
398
|
+
for scenario in response.user_scenarios:
|
|
399
|
+
eval_case = types.EvalCase(
|
|
400
|
+
user_scenario=scenario,
|
|
401
|
+
)
|
|
402
|
+
eval_cases.append(eval_case)
|
|
403
|
+
data_for_df.append(
|
|
404
|
+
{
|
|
405
|
+
"starting_prompt": scenario.starting_prompt,
|
|
406
|
+
"conversation_plan": scenario.conversation_plan,
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
eval_dataset_df = None
|
|
410
|
+
if pd is not None:
|
|
411
|
+
eval_dataset_df = pd.DataFrame(data_for_df)
|
|
412
|
+
else:
|
|
413
|
+
logger.warning("Pandas is not installed. eval_dataset_df will be None.")
|
|
414
|
+
return types.EvaluationDataset(
|
|
415
|
+
eval_cases=eval_cases, eval_dataset_df=eval_dataset_df
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _display_loss_analysis_result(
|
|
420
|
+
result: types.LossAnalysisResult,
|
|
421
|
+
) -> None:
|
|
422
|
+
"""Displays a LossAnalysisResult as a formatted pandas DataFrame."""
|
|
423
|
+
metric = result.config.metric if result.config else None
|
|
424
|
+
candidate = result.config.candidate if result.config else None
|
|
425
|
+
rows: list[dict[str, Any]] = []
|
|
426
|
+
for cluster in result.clusters or []:
|
|
427
|
+
entry = cluster.taxonomy_entry
|
|
428
|
+
row = {
|
|
429
|
+
"metric": metric,
|
|
430
|
+
"candidate": candidate,
|
|
431
|
+
"cluster_id": cluster.cluster_id,
|
|
432
|
+
"l1_category": entry.l1_category if entry else None,
|
|
433
|
+
"l2_category": entry.l2_category if entry else None,
|
|
434
|
+
"description": entry.description if entry else None,
|
|
435
|
+
"item_count": cluster.item_count,
|
|
436
|
+
}
|
|
437
|
+
rows.append(row)
|
|
438
|
+
|
|
439
|
+
if not rows:
|
|
440
|
+
logger.info("No loss clusters found.")
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
df = pd.DataFrame(rows)
|
|
444
|
+
try:
|
|
445
|
+
from IPython.display import display # pylint: disable=g-import-not-at-top
|
|
446
|
+
|
|
447
|
+
display(df)
|
|
448
|
+
except ImportError:
|
|
449
|
+
print(df.to_string()) # pylint: disable=print-function
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _resolve_metric_name(
|
|
453
|
+
metric: Optional[Any],
|
|
454
|
+
) -> Optional[str]:
|
|
455
|
+
"""Extracts a metric name string from a metric argument.
|
|
456
|
+
|
|
457
|
+
Accepts a string, a Metric object, or a LazyLoadedPrebuiltMetric
|
|
458
|
+
(RubricMetric) and returns the metric name as a string.
|
|
459
|
+
|
|
460
|
+
For LazyLoadedPrebuiltMetric (e.g., RubricMetric.MULTI_TURN_TASK_SUCCESS),
|
|
461
|
+
this resolves to the API metric spec name (e.g.,
|
|
462
|
+
"multi_turn_task_success_v1") so it matches the keys in eval results.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
metric: A metric name string, Metric object, RubricMetric enum value, or
|
|
466
|
+
None.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
The metric name as a string, or None if metric is None.
|
|
470
|
+
"""
|
|
471
|
+
if metric is None:
|
|
472
|
+
return None
|
|
473
|
+
if isinstance(metric, str):
|
|
474
|
+
return metric
|
|
475
|
+
# LazyLoadedPrebuiltMetric: resolve to versioned API spec name.
|
|
476
|
+
if hasattr(metric, "_get_api_metric_spec_name"):
|
|
477
|
+
spec_name: Optional[str] = metric._get_api_metric_spec_name()
|
|
478
|
+
if spec_name:
|
|
479
|
+
return spec_name
|
|
480
|
+
# Metric objects and other types with a .name attribute.
|
|
481
|
+
if hasattr(metric, "name"):
|
|
482
|
+
return str(metric.name)
|
|
483
|
+
return str(metric)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _resolve_eval_run_loss_configs(
|
|
487
|
+
loss_analysis_metrics: Optional[list[Any]] = None,
|
|
488
|
+
loss_analysis_configs: Optional[list[Any]] = None,
|
|
489
|
+
inference_configs: Optional[dict[str, Any]] = None,
|
|
490
|
+
) -> Optional[list[types.LossAnalysisConfig]]:
|
|
491
|
+
"""Resolves loss analysis configs for create_evaluation_run.
|
|
492
|
+
|
|
493
|
+
Supports two modes:
|
|
494
|
+
1. ``loss_analysis_metrics``: A simplified list of metrics. The candidate
|
|
495
|
+
is auto-inferred from ``inference_configs`` when there is exactly one
|
|
496
|
+
candidate. Each metric is resolved via ``_resolve_metric_name()``.
|
|
497
|
+
2. ``loss_analysis_configs``: Explicit ``LossAnalysisConfig`` objects or
|
|
498
|
+
dicts for full control.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
loss_analysis_metrics: Optional list of metric references (strings,
|
|
502
|
+
Metric objects, or RubricMetric enums).
|
|
503
|
+
loss_analysis_configs: Optional list of LossAnalysisConfig or dicts.
|
|
504
|
+
inference_configs: The resolved inference_configs dict (candidate name
|
|
505
|
+
-> config). Used to auto-infer candidate for the metrics path.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
A list of resolved LossAnalysisConfig objects, or None if neither
|
|
509
|
+
loss_analysis_metrics nor loss_analysis_configs is provided.
|
|
510
|
+
|
|
511
|
+
Raises:
|
|
512
|
+
ValueError: If candidate cannot be inferred for loss_analysis_metrics.
|
|
513
|
+
"""
|
|
514
|
+
if not loss_analysis_metrics and not loss_analysis_configs:
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
if loss_analysis_configs:
|
|
518
|
+
return [
|
|
519
|
+
types.LossAnalysisConfig.model_validate(c) if isinstance(c, dict) else c
|
|
520
|
+
for c in loss_analysis_configs
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
# loss_analysis_metrics path: auto-infer candidate from inference_configs
|
|
524
|
+
candidate = None
|
|
525
|
+
if inference_configs and len(inference_configs) == 1:
|
|
526
|
+
candidate = next(iter(inference_configs))
|
|
527
|
+
elif inference_configs and len(inference_configs) > 1:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
"Cannot infer candidate for loss analysis: multiple candidates"
|
|
530
|
+
f" found in inference_configs: {list(inference_configs.keys())}."
|
|
531
|
+
" Please use loss_analysis_configs with explicit candidate values"
|
|
532
|
+
" instead."
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
configs = []
|
|
536
|
+
for m in loss_analysis_metrics or []:
|
|
537
|
+
metric_name = _resolve_metric_name(m)
|
|
538
|
+
configs.append(
|
|
539
|
+
types.LossAnalysisConfig(metric=metric_name, candidate=candidate)
|
|
540
|
+
)
|
|
541
|
+
return configs
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _resolve_red_teaming_config(
|
|
545
|
+
red_teaming_config: Optional[types.RedTeamingAnalysisConfigOrDict] = None,
|
|
546
|
+
) -> Optional[list[types.AnalysisConfig]]:
|
|
547
|
+
"""Wraps a RedTeamingAnalysisConfig into analysis_configs for the API."""
|
|
548
|
+
if not red_teaming_config:
|
|
549
|
+
return None
|
|
550
|
+
config = (
|
|
551
|
+
types.RedTeamingAnalysisConfig.model_validate(red_teaming_config)
|
|
552
|
+
if isinstance(red_teaming_config, dict)
|
|
553
|
+
else red_teaming_config
|
|
554
|
+
)
|
|
555
|
+
return [types.AnalysisConfig(red_teaming_analysis_config=config)]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _resolve_loss_analysis_config(
|
|
559
|
+
eval_result: types.EvaluationResult,
|
|
560
|
+
config: Optional[types.LossAnalysisConfig] = None,
|
|
561
|
+
metric: Optional[str] = None,
|
|
562
|
+
candidate: Optional[str] = None,
|
|
563
|
+
) -> types.LossAnalysisConfig:
|
|
564
|
+
"""Resolves and validates the LossAnalysisConfig for generate_loss_clusters.
|
|
565
|
+
|
|
566
|
+
Auto-infers `metric` and `candidate` from the EvaluationResult when not
|
|
567
|
+
explicitly provided. Validates that provided values exist in the eval result.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
eval_result: The EvaluationResult from client.evals.evaluate().
|
|
571
|
+
config: Optional explicit LossAnalysisConfig. If provided, metric and
|
|
572
|
+
candidate from config take precedence over the separate arguments.
|
|
573
|
+
metric: Optional metric name override.
|
|
574
|
+
candidate: Optional candidate name override.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
A resolved LossAnalysisConfig with metric and candidate populated.
|
|
578
|
+
|
|
579
|
+
Raises:
|
|
580
|
+
ValueError: If metric/candidate cannot be inferred or are invalid.
|
|
581
|
+
"""
|
|
582
|
+
# Start from config if provided, otherwise create a new one.
|
|
583
|
+
if config is not None:
|
|
584
|
+
resolved_metric = metric or config.metric
|
|
585
|
+
resolved_candidate = candidate or config.candidate
|
|
586
|
+
resolved_config = config.model_copy(
|
|
587
|
+
update={"metric": resolved_metric, "candidate": resolved_candidate}
|
|
588
|
+
)
|
|
589
|
+
else:
|
|
590
|
+
resolved_config = types.LossAnalysisConfig(metric=metric, candidate=candidate)
|
|
591
|
+
|
|
592
|
+
# Collect available metric names from the eval result.
|
|
593
|
+
available_metrics: set[str] = set()
|
|
594
|
+
if eval_result.eval_case_results:
|
|
595
|
+
for case_result in eval_result.eval_case_results:
|
|
596
|
+
for resp_cand in case_result.response_candidate_results or []:
|
|
597
|
+
for m_name in (resp_cand.metric_results or {}).keys():
|
|
598
|
+
available_metrics.add(m_name)
|
|
599
|
+
|
|
600
|
+
# Collect available candidate names from metadata.
|
|
601
|
+
available_candidates: list[str] = []
|
|
602
|
+
if eval_result.metadata and eval_result.metadata.candidate_names:
|
|
603
|
+
available_candidates = list(eval_result.metadata.candidate_names)
|
|
604
|
+
|
|
605
|
+
# Auto-infer metric if not provided.
|
|
606
|
+
if not resolved_config.metric:
|
|
607
|
+
if len(available_metrics) == 1:
|
|
608
|
+
resolved_config = resolved_config.model_copy(
|
|
609
|
+
update={"metric": next(iter(available_metrics))}
|
|
610
|
+
)
|
|
611
|
+
elif len(available_metrics) == 0:
|
|
612
|
+
raise ValueError(
|
|
613
|
+
"Cannot infer metric: no metric results found in eval_result."
|
|
614
|
+
" Please provide metric explicitly via"
|
|
615
|
+
" config=types.LossAnalysisConfig(metric='...')."
|
|
616
|
+
)
|
|
617
|
+
else:
|
|
618
|
+
raise ValueError(
|
|
619
|
+
"Cannot infer metric: multiple metrics found in eval_result:"
|
|
620
|
+
f" {sorted(available_metrics)}. Please provide metric"
|
|
621
|
+
" explicitly via config=types.LossAnalysisConfig(metric='...')."
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Validate metric if provided explicitly.
|
|
625
|
+
if available_metrics and resolved_config.metric not in available_metrics:
|
|
626
|
+
raise ValueError(
|
|
627
|
+
f"Metric '{resolved_config.metric}' not found in eval_result."
|
|
628
|
+
f" Available metrics: {sorted(available_metrics)}."
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
# Auto-infer candidate if not provided.
|
|
632
|
+
if not resolved_config.candidate:
|
|
633
|
+
if len(available_candidates) == 1:
|
|
634
|
+
resolved_config = resolved_config.model_copy(
|
|
635
|
+
update={"candidate": available_candidates[0]}
|
|
636
|
+
)
|
|
637
|
+
elif len(available_candidates) == 0:
|
|
638
|
+
# Fallback: use default candidate naming convention from SDK.
|
|
639
|
+
resolved_config = resolved_config.model_copy(
|
|
640
|
+
update={"candidate": "candidate_1"}
|
|
641
|
+
)
|
|
642
|
+
logger.warning(
|
|
643
|
+
"No candidate names found in eval_result.metadata."
|
|
644
|
+
" Defaulting to 'candidate_1'. If this is incorrect, provide"
|
|
645
|
+
" candidate explicitly via"
|
|
646
|
+
" config=types.LossAnalysisConfig(candidate='...')."
|
|
647
|
+
)
|
|
648
|
+
else:
|
|
649
|
+
raise ValueError(
|
|
650
|
+
"Cannot infer candidate: multiple candidates found in"
|
|
651
|
+
f" eval_result: {available_candidates}. Please provide"
|
|
652
|
+
" candidate explicitly via"
|
|
653
|
+
" config=types.LossAnalysisConfig(candidate='...')."
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# Validate candidate if provided explicitly and candidates are known.
|
|
657
|
+
if available_candidates and resolved_config.candidate not in available_candidates:
|
|
658
|
+
raise ValueError(
|
|
659
|
+
f"Candidate '{resolved_config.candidate}' not found in"
|
|
660
|
+
f" eval_result. Available candidates: {available_candidates}."
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
return resolved_config
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _build_rubric_description_map(
|
|
667
|
+
eval_result: types.EvaluationResult,
|
|
668
|
+
) -> dict[str, str]:
|
|
669
|
+
"""Builds a rubric_id -> description map from the EvaluationResult."""
|
|
670
|
+
rubric_map: dict[str, str] = {}
|
|
671
|
+
for case_result in eval_result.eval_case_results or []:
|
|
672
|
+
for resp_cand in case_result.response_candidate_results or []:
|
|
673
|
+
for metric_res in (resp_cand.metric_results or {}).values():
|
|
674
|
+
for verdict in metric_res.rubric_verdicts or []:
|
|
675
|
+
rubric = verdict.evaluated_rubric
|
|
676
|
+
if rubric and rubric.rubric_id and rubric.content:
|
|
677
|
+
if (
|
|
678
|
+
rubric.content.property
|
|
679
|
+
and rubric.content.property.description
|
|
680
|
+
):
|
|
681
|
+
rubric_map[rubric.rubric_id] = (
|
|
682
|
+
rubric.content.property.description
|
|
683
|
+
)
|
|
684
|
+
return rubric_map
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _extract_scenario_preview_from_dict(
|
|
688
|
+
eval_result_dict: dict[str, Any],
|
|
689
|
+
) -> Optional[str]:
|
|
690
|
+
"""Extracts the first user message from an evaluation_result dict.
|
|
691
|
+
|
|
692
|
+
Handles both snake_case (SDK-side) and camelCase (API echo-back) keys.
|
|
693
|
+
"""
|
|
694
|
+
request = eval_result_dict.get("request")
|
|
695
|
+
if not request:
|
|
696
|
+
return None
|
|
697
|
+
prompt = request.get("prompt")
|
|
698
|
+
if not prompt:
|
|
699
|
+
return None
|
|
700
|
+
# Try agent_data (snake_case or camelCase)
|
|
701
|
+
agent_data = prompt.get("agent_data") or prompt.get("agentData")
|
|
702
|
+
if agent_data and isinstance(agent_data, dict):
|
|
703
|
+
turns = agent_data.get("turns", [])
|
|
704
|
+
for turn in turns:
|
|
705
|
+
events = turn.get("events", [])
|
|
706
|
+
for event in events:
|
|
707
|
+
author = event.get("author", "")
|
|
708
|
+
content = event.get("content")
|
|
709
|
+
if author.lower() == "user" and content and isinstance(content, dict):
|
|
710
|
+
parts = content.get("parts", [])
|
|
711
|
+
for part in parts:
|
|
712
|
+
text = str(part.get("text", "")).strip()
|
|
713
|
+
if text:
|
|
714
|
+
if len(text) > 150:
|
|
715
|
+
return text[:150] + "..."
|
|
716
|
+
return text
|
|
717
|
+
# Try simple prompt path
|
|
718
|
+
parts = prompt.get("parts", [])
|
|
719
|
+
for part in parts:
|
|
720
|
+
text = str(part.get("text", "")).strip()
|
|
721
|
+
if text:
|
|
722
|
+
if len(text) > 150:
|
|
723
|
+
return text[:150] + "..."
|
|
724
|
+
return text
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _extract_scenario_from_agent_data(agent_data: Any) -> Optional[str]:
|
|
729
|
+
"""Extracts the first user message from an AgentData object or dict."""
|
|
730
|
+
if agent_data is None:
|
|
731
|
+
return None
|
|
732
|
+
if hasattr(agent_data, "model_dump"):
|
|
733
|
+
agent_data = agent_data.model_dump()
|
|
734
|
+
if isinstance(agent_data, str):
|
|
735
|
+
try:
|
|
736
|
+
agent_data = json.loads(agent_data)
|
|
737
|
+
except (json.JSONDecodeError, ValueError):
|
|
738
|
+
return None
|
|
739
|
+
if not isinstance(agent_data, dict):
|
|
740
|
+
return None
|
|
741
|
+
turns = agent_data.get("turns", [])
|
|
742
|
+
if not isinstance(turns, list):
|
|
743
|
+
return None
|
|
744
|
+
for turn in turns:
|
|
745
|
+
if not isinstance(turn, dict):
|
|
746
|
+
continue
|
|
747
|
+
events = turn.get("events", [])
|
|
748
|
+
if not isinstance(events, list):
|
|
749
|
+
continue
|
|
750
|
+
for event in events:
|
|
751
|
+
if not isinstance(event, dict):
|
|
752
|
+
continue
|
|
753
|
+
author = event.get("author", "")
|
|
754
|
+
if not isinstance(author, str) or author.lower() != "user":
|
|
755
|
+
continue
|
|
756
|
+
content = event.get("content")
|
|
757
|
+
if not content or not isinstance(content, dict):
|
|
758
|
+
continue
|
|
759
|
+
parts = content.get("parts", [])
|
|
760
|
+
if not isinstance(parts, list):
|
|
761
|
+
continue
|
|
762
|
+
for part in parts:
|
|
763
|
+
if not isinstance(part, dict):
|
|
764
|
+
continue
|
|
765
|
+
text = str(part.get("text", "")).strip()
|
|
766
|
+
if text:
|
|
767
|
+
if len(text) > 150:
|
|
768
|
+
return text[:150] + "..."
|
|
769
|
+
return text
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _truncate_scenario(text: str, max_len: int = 150) -> str:
|
|
774
|
+
"""Truncates a scenario preview to max_len characters."""
|
|
775
|
+
text = text.strip()
|
|
776
|
+
if len(text) > max_len:
|
|
777
|
+
return text[:max_len] + "..."
|
|
778
|
+
return text
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _build_scenario_preview_list(
|
|
782
|
+
eval_result: types.EvaluationResult,
|
|
783
|
+
) -> list[Optional[str]]:
|
|
784
|
+
"""Builds an ordered list of scenario previews from the EvaluationResult.
|
|
785
|
+
|
|
786
|
+
Returns one scenario preview per eval_case_result, in the same order as
|
|
787
|
+
eval_case_results. This extracts the first user message from the original
|
|
788
|
+
SDK EvaluationResult (via eval_cases or DataFrame), rather than relying
|
|
789
|
+
on the API echo-back which may not preserve the request data.
|
|
790
|
+
|
|
791
|
+
Extraction priority per eval case:
|
|
792
|
+
1. eval_case.agent_data → first user message in turns
|
|
793
|
+
2. eval_case.user_scenario.starting_prompt
|
|
794
|
+
3. eval_case.prompt → text content
|
|
795
|
+
4. DataFrame agent_data column → first user message
|
|
796
|
+
5. DataFrame starting_prompt column
|
|
797
|
+
"""
|
|
798
|
+
eval_dataset = eval_result.evaluation_dataset
|
|
799
|
+
eval_cases: list[Any] = []
|
|
800
|
+
if isinstance(eval_dataset, list) and eval_dataset:
|
|
801
|
+
eval_cases = getv(eval_dataset[0], ["eval_cases"]) or []
|
|
802
|
+
|
|
803
|
+
eval_case_results = eval_result.eval_case_results or []
|
|
804
|
+
scenarios: list[Optional[str]] = []
|
|
805
|
+
|
|
806
|
+
for case_result in eval_case_results:
|
|
807
|
+
case_idx = case_result.eval_case_index or 0
|
|
808
|
+
scenario: Optional[str] = None
|
|
809
|
+
|
|
810
|
+
eval_case = None
|
|
811
|
+
if 0 <= case_idx < len(eval_cases):
|
|
812
|
+
eval_case = eval_cases[case_idx]
|
|
813
|
+
|
|
814
|
+
if eval_case:
|
|
815
|
+
# 1. Try agent_data (populated after run_inference)
|
|
816
|
+
agent_data = getv(eval_case, ["agent_data"])
|
|
817
|
+
if agent_data:
|
|
818
|
+
scenario = _extract_scenario_from_agent_data(agent_data)
|
|
819
|
+
|
|
820
|
+
# 2. Try user_scenario.starting_prompt (from
|
|
821
|
+
# generate_conversation_scenarios)
|
|
822
|
+
if scenario is None:
|
|
823
|
+
user_scenario = getv(eval_case, ["user_scenario"])
|
|
824
|
+
if user_scenario:
|
|
825
|
+
starting_prompt = getv(user_scenario, ["starting_prompt"])
|
|
826
|
+
if starting_prompt and isinstance(starting_prompt, str):
|
|
827
|
+
scenario = _truncate_scenario(starting_prompt)
|
|
828
|
+
|
|
829
|
+
# 3. Try prompt text
|
|
830
|
+
if scenario is None:
|
|
831
|
+
prompt = getv(eval_case, ["prompt"])
|
|
832
|
+
if prompt:
|
|
833
|
+
from . import _evals_data_converters
|
|
834
|
+
|
|
835
|
+
text = _evals_data_converters._get_content_text(prompt)
|
|
836
|
+
if text:
|
|
837
|
+
scenario = _truncate_scenario(str(text))
|
|
838
|
+
|
|
839
|
+
# 4. Fallback: extract agent_data from DataFrame
|
|
840
|
+
if scenario is None and eval_dataset:
|
|
841
|
+
df_agent_data = _transformers._extract_agent_data_from_df(
|
|
842
|
+
eval_dataset, case_idx
|
|
843
|
+
)
|
|
844
|
+
if df_agent_data is not None:
|
|
845
|
+
scenario = _extract_scenario_from_agent_data(df_agent_data)
|
|
846
|
+
|
|
847
|
+
# 5. Fallback: extract starting_prompt from DataFrame
|
|
848
|
+
if scenario is None and eval_dataset:
|
|
849
|
+
ds = eval_dataset[0] if isinstance(eval_dataset, list) else eval_dataset
|
|
850
|
+
df = getv(ds, ["eval_dataset_df"])
|
|
851
|
+
if df is not None and hasattr(df, "iloc"):
|
|
852
|
+
if 0 <= case_idx < len(df):
|
|
853
|
+
row = df.iloc[case_idx]
|
|
854
|
+
sp = row.get("starting_prompt")
|
|
855
|
+
if sp and isinstance(sp, str) and sp.strip():
|
|
856
|
+
scenario = _truncate_scenario(sp)
|
|
857
|
+
|
|
858
|
+
scenarios.append(scenario)
|
|
859
|
+
|
|
860
|
+
return scenarios
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _enrich_loss_response_with_rubric_descriptions(
|
|
864
|
+
response: types.GenerateLossClustersResponse,
|
|
865
|
+
eval_result: types.EvaluationResult,
|
|
866
|
+
) -> None:
|
|
867
|
+
"""Enriches loss response with rubric descriptions and scenario previews.
|
|
868
|
+
|
|
869
|
+
Rubric descriptions and scenario previews are extracted from the original
|
|
870
|
+
SDK EvaluationResult object, because the API echo-back in
|
|
871
|
+
LossExample.evaluation_result may not preserve all request data (e.g.,
|
|
872
|
+
agent_data turns with user messages).
|
|
873
|
+
"""
|
|
874
|
+
rubric_map = _build_rubric_description_map(eval_result)
|
|
875
|
+
scenario_list = _build_scenario_preview_list(eval_result)
|
|
876
|
+
logger.debug(
|
|
877
|
+
"Enriching loss response: %d scenarios extracted, %d rubric" " descriptions",
|
|
878
|
+
sum(1 for s in scenario_list if s),
|
|
879
|
+
len(rubric_map),
|
|
880
|
+
)
|
|
881
|
+
for result in response.results or []:
|
|
882
|
+
for cluster in result.clusters or []:
|
|
883
|
+
for example in cluster.examples or []:
|
|
884
|
+
if example.evaluation_result is None:
|
|
885
|
+
example.evaluation_result = {}
|
|
886
|
+
if rubric_map:
|
|
887
|
+
example.evaluation_result["rubric_descriptions"] = rubric_map
|
|
888
|
+
# Try extracting scenario from the API echo-back first
|
|
889
|
+
if "scenario_preview" not in example.evaluation_result:
|
|
890
|
+
scenario = _extract_scenario_preview_from_dict(
|
|
891
|
+
example.evaluation_result
|
|
892
|
+
)
|
|
893
|
+
if scenario:
|
|
894
|
+
example.evaluation_result["scenario_preview"] = scenario
|
|
895
|
+
# Fallback: match against scenarios from original eval_result
|
|
896
|
+
if "scenario_preview" not in example.evaluation_result:
|
|
897
|
+
if scenario_list:
|
|
898
|
+
for s in scenario_list:
|
|
899
|
+
if s:
|
|
900
|
+
example.evaluation_result["scenario_preview"] = s
|
|
901
|
+
break
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _poll_operation(
|
|
905
|
+
api_client: BaseApiClient,
|
|
906
|
+
operation: types.GenerateLossClustersOperation,
|
|
907
|
+
poll_interval_seconds: float = 5.0,
|
|
908
|
+
) -> types.GenerateLossClustersOperation:
|
|
909
|
+
"""Polls a long-running operation until completion.
|
|
910
|
+
|
|
911
|
+
Args:
|
|
912
|
+
api_client: The API client to use for polling.
|
|
913
|
+
operation: The initial operation returned from the API call.
|
|
914
|
+
poll_interval_seconds: Time between polls.
|
|
915
|
+
|
|
916
|
+
Returns:
|
|
917
|
+
The completed operation.
|
|
918
|
+
"""
|
|
919
|
+
if operation.done:
|
|
920
|
+
return operation
|
|
921
|
+
start_time = time.time()
|
|
922
|
+
while True:
|
|
923
|
+
response = api_client.request("get", operation.name, {}, None)
|
|
924
|
+
response_dict = {} if not response.body else json.loads(response.body)
|
|
925
|
+
polled = types.GenerateLossClustersOperation._from_response(
|
|
926
|
+
response=response_dict, kwargs={}
|
|
927
|
+
)
|
|
928
|
+
if polled.done:
|
|
929
|
+
return polled
|
|
930
|
+
elapsed = int(time.time() - start_time)
|
|
931
|
+
logger.info(
|
|
932
|
+
"Loss analysis operation still running... Elapsed time: %d seconds",
|
|
933
|
+
elapsed,
|
|
934
|
+
)
|
|
935
|
+
time.sleep(poll_interval_seconds)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
async def _poll_operation_async(
|
|
939
|
+
api_client: BaseApiClient,
|
|
940
|
+
operation: types.GenerateLossClustersOperation,
|
|
941
|
+
poll_interval_seconds: float = 5.0,
|
|
942
|
+
) -> types.GenerateLossClustersOperation:
|
|
943
|
+
"""Polls a long-running operation until completion (async).
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
api_client: The API client to use for polling.
|
|
947
|
+
operation: The initial operation returned from the API call.
|
|
948
|
+
poll_interval_seconds: Time between polls.
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
The completed operation.
|
|
952
|
+
"""
|
|
953
|
+
if operation.done:
|
|
954
|
+
return operation
|
|
955
|
+
start_time = time.time()
|
|
956
|
+
while True:
|
|
957
|
+
response = await api_client.async_request("get", operation.name, {}, None)
|
|
958
|
+
response_dict = {} if not response.body else json.loads(response.body)
|
|
959
|
+
polled = types.GenerateLossClustersOperation._from_response(
|
|
960
|
+
response=response_dict, kwargs={}
|
|
961
|
+
)
|
|
962
|
+
if polled.done:
|
|
963
|
+
return polled
|
|
964
|
+
elapsed = int(time.time() - start_time)
|
|
965
|
+
logger.info(
|
|
966
|
+
"Loss analysis operation still running... Elapsed time: %d seconds",
|
|
967
|
+
elapsed,
|
|
968
|
+
)
|
|
969
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _validate_dataset_agent_data(
|
|
973
|
+
dataset: types.EvaluationDataset,
|
|
974
|
+
inference_configs: Optional[dict[str, Any]] = None,
|
|
975
|
+
) -> None:
|
|
976
|
+
"""Validates agent_data in the EvaluationDataset.
|
|
977
|
+
|
|
978
|
+
Checks that agent_data matches the expected AgentData type and that
|
|
979
|
+
'agents' are not defined in both the dataset's agent_data and inference_configs.
|
|
980
|
+
"""
|
|
981
|
+
has_inference_agent_configs = False
|
|
982
|
+
if inference_configs:
|
|
983
|
+
for cand_config in inference_configs.values():
|
|
984
|
+
if isinstance(cand_config, dict) and cand_config.get("agent_configs"):
|
|
985
|
+
has_inference_agent_configs = True
|
|
986
|
+
elif hasattr(cand_config, "agent_configs") and cand_config.agent_configs:
|
|
987
|
+
has_inference_agent_configs = True
|
|
988
|
+
|
|
989
|
+
def _validate_single_agent_data(agent_data_val: Any, identifier: str) -> None:
|
|
990
|
+
|
|
991
|
+
if not agent_data_val:
|
|
992
|
+
return
|
|
993
|
+
|
|
994
|
+
agent_data_obj = None
|
|
995
|
+
if isinstance(agent_data_val, str):
|
|
996
|
+
try:
|
|
997
|
+
agent_data_val = json.loads(agent_data_val)
|
|
998
|
+
if "error" in agent_data_val:
|
|
999
|
+
return
|
|
1000
|
+
agent_data_obj = types.evals.AgentData.model_validate(agent_data_val)
|
|
1001
|
+
except json.JSONDecodeError as e:
|
|
1002
|
+
raise ValueError(
|
|
1003
|
+
f"{identifier}: 'agent_data' is not valid JSON: {e}"
|
|
1004
|
+
) from e
|
|
1005
|
+
elif isinstance(agent_data_val, dict) and "error" in agent_data_val:
|
|
1006
|
+
return
|
|
1007
|
+
elif isinstance(agent_data_val, dict):
|
|
1008
|
+
try:
|
|
1009
|
+
agent_data_obj = types.evals.AgentData.model_validate(agent_data_val)
|
|
1010
|
+
except Exception as e:
|
|
1011
|
+
raise ValueError(
|
|
1012
|
+
f"{identifier}: 'agent_data' "
|
|
1013
|
+
f"is inconsistent with AgentData type: {e}"
|
|
1014
|
+
) from e
|
|
1015
|
+
elif isinstance(agent_data_val, types.evals.AgentData):
|
|
1016
|
+
agent_data_obj = agent_data_val
|
|
1017
|
+
else:
|
|
1018
|
+
raise ValueError(
|
|
1019
|
+
f"{identifier}: 'agent_data' is inconsistent with AgentData type. "
|
|
1020
|
+
f"Got {type(agent_data_val)}"
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
if agent_data_obj and agent_data_obj.agents and has_inference_agent_configs:
|
|
1024
|
+
raise ValueError(
|
|
1025
|
+
f"{identifier}: Cannot provide 'agents' in the dataset's 'agent_data' "
|
|
1026
|
+
"and 'agent_configs' in inference_configs at the same time."
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
if (
|
|
1030
|
+
dataset.eval_dataset_df is not None
|
|
1031
|
+
and "agent_data" in dataset.eval_dataset_df.columns
|
|
1032
|
+
):
|
|
1033
|
+
for idx, row in dataset.eval_dataset_df.iterrows():
|
|
1034
|
+
_validate_single_agent_data(row.get("agent_data"), f"Row {idx}")
|
|
1035
|
+
|
|
1036
|
+
if dataset.eval_cases:
|
|
1037
|
+
for idx, eval_case in enumerate(dataset.eval_cases):
|
|
1038
|
+
agent_data = None
|
|
1039
|
+
if isinstance(eval_case, dict):
|
|
1040
|
+
agent_data = eval_case.get("agent_data", None)
|
|
1041
|
+
elif hasattr(eval_case, "agent_data"):
|
|
1042
|
+
agent_data = eval_case.agent_data
|
|
1043
|
+
_validate_single_agent_data(agent_data, f"EvalCase {idx}")
|