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.
Files changed (62) hide show
  1. agentplatform/__init__.py +72 -0
  2. agentplatform/_genai/__init__.py +43 -0
  3. agentplatform/_genai/_agent_engines_utils.py +2341 -0
  4. agentplatform/_genai/_bigquery_utils.py +49 -0
  5. agentplatform/_genai/_datasets_utils.py +344 -0
  6. agentplatform/_genai/_evals_builtin_tools.py +209 -0
  7. agentplatform/_genai/_evals_common.py +4268 -0
  8. agentplatform/_genai/_evals_constant.py +122 -0
  9. agentplatform/_genai/_evals_data_converters.py +926 -0
  10. agentplatform/_genai/_evals_metric_handlers.py +1783 -0
  11. agentplatform/_genai/_evals_metric_loaders.py +401 -0
  12. agentplatform/_genai/_evals_utils.py +1043 -0
  13. agentplatform/_genai/_evals_visualization.py +2070 -0
  14. agentplatform/_genai/_gcs_utils.py +262 -0
  15. agentplatform/_genai/_logging_utils.py +47 -0
  16. agentplatform/_genai/_memory_bank_utils.py +206 -0
  17. agentplatform/_genai/_observability_data_converter.py +186 -0
  18. agentplatform/_genai/_operations_utils.py +94 -0
  19. agentplatform/_genai/_prompt_management_utils.py +147 -0
  20. agentplatform/_genai/_prompt_optimizer_utils.py +215 -0
  21. agentplatform/_genai/_skills_utils.py +69 -0
  22. agentplatform/_genai/_transformers.py +628 -0
  23. agentplatform/_genai/a2a_task_events.py +509 -0
  24. agentplatform/_genai/a2a_tasks.py +861 -0
  25. agentplatform/_genai/agent_engines.py +3931 -0
  26. agentplatform/_genai/client.py +519 -0
  27. agentplatform/_genai/datasets.py +3045 -0
  28. agentplatform/_genai/endpoints.py +1149 -0
  29. agentplatform/_genai/evals.py +6883 -0
  30. agentplatform/_genai/example_stores.py +1445 -0
  31. agentplatform/_genai/feedback_contexts.py +700 -0
  32. agentplatform/_genai/feedback_entries.py +1644 -0
  33. agentplatform/_genai/live.py +64 -0
  34. agentplatform/_genai/live_agent_engines.py +179 -0
  35. agentplatform/_genai/memories.py +2962 -0
  36. agentplatform/_genai/memory_banks.py +1927 -0
  37. agentplatform/_genai/memory_revisions.py +465 -0
  38. agentplatform/_genai/model_garden.py +2638 -0
  39. agentplatform/_genai/prompt_optimizer.py +995 -0
  40. agentplatform/_genai/prompts.py +4515 -0
  41. agentplatform/_genai/rag.py +4961 -0
  42. agentplatform/_genai/runtime_revisions.py +1257 -0
  43. agentplatform/_genai/runtimes.py +78 -0
  44. agentplatform/_genai/sandbox_snapshots.py +1015 -0
  45. agentplatform/_genai/sandbox_templates.py +1088 -0
  46. agentplatform/_genai/sandboxes.py +1604 -0
  47. agentplatform/_genai/session_events.py +543 -0
  48. agentplatform/_genai/sessions.py +1449 -0
  49. agentplatform/_genai/skill_revisions.py +377 -0
  50. agentplatform/_genai/skills.py +1708 -0
  51. agentplatform/_genai/types/__init__.py +4695 -0
  52. agentplatform/_genai/types/agent_engines.py +16 -0
  53. agentplatform/_genai/types/common.py +32784 -0
  54. agentplatform/_genai/types/evals.py +1031 -0
  55. agentplatform/_genai/types/prompt_optimizer.py +107 -0
  56. agentplatform/_genai/types/prompts.py +107 -0
  57. agentplatform/version.py +17 -0
  58. google_cloud_agentplatform-1.165.1.dev0.dist-info/METADATA +79 -0
  59. google_cloud_agentplatform-1.165.1.dev0.dist-info/RECORD +62 -0
  60. google_cloud_agentplatform-1.165.1.dev0.dist-info/WHEEL +5 -0
  61. google_cloud_agentplatform-1.165.1.dev0.dist-info/licenses/LICENSE +202 -0
  62. google_cloud_agentplatform-1.165.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,926 @@
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
+ """Dataset converters for evals."""
16
+
17
+ import copy
18
+ import json
19
+ import logging
20
+ from typing import Any, Optional, Union
21
+
22
+ from google.genai import _common
23
+ from google.genai import types as genai_types
24
+ from pydantic import ValidationError
25
+ from typing_extensions import override
26
+
27
+ from . import _evals_utils
28
+ from . import _observability_data_converter
29
+ from . import types
30
+
31
+
32
+ logger = logging.getLogger("agentplatform_genai._evals_data_converters")
33
+
34
+
35
+ class EvalDatasetSchema(_common.CaseInSensitiveEnum):
36
+ """Represents the schema of an evaluation dataset."""
37
+
38
+ GEMINI = "gemini"
39
+ FLATTEN = "flatten"
40
+ OPENAI = "openai"
41
+ OBSERVABILITY = "observability"
42
+ UNKNOWN = "unknown"
43
+
44
+
45
+ _PLACEHOLDER_RESPONSE_TEXT = "Error: Missing response for this candidate"
46
+
47
+
48
+ def _create_placeholder_response_candidate(
49
+ text: str = _PLACEHOLDER_RESPONSE_TEXT,
50
+ ) -> types.ResponseCandidate:
51
+ """Creates a ResponseCandidate with placeholder text."""
52
+ return types.ResponseCandidate(
53
+ response=genai_types.Content(parts=[genai_types.Part(text=text)])
54
+ )
55
+
56
+
57
+ class _GeminiEvalDataConverter(_evals_utils.EvalDataConverter):
58
+ """Converter for dataset in the Gemini format."""
59
+
60
+ def _parse_request(self, request_data: dict[str, Any]) -> tuple[
61
+ genai_types.Content,
62
+ genai_types.Content,
63
+ list[types.evals.Message],
64
+ types.ResponseCandidate,
65
+ ]:
66
+ """Parses a request from a Gemini dataset."""
67
+ system_instruction = genai_types.Content()
68
+ prompt = genai_types.Content()
69
+ reference = types.ResponseCandidate()
70
+ conversation_history = []
71
+
72
+ if "system_instruction" in request_data:
73
+ system_instruction = genai_types.Content.model_validate(
74
+ request_data["system_instruction"]
75
+ )
76
+ for turn_id, content_dict in enumerate(request_data.get("contents", [])):
77
+ if not isinstance(content_dict, dict):
78
+ raise TypeError(
79
+ "Expected a dictionary for content at turn %s, but got %s: %s"
80
+ % (turn_id, type(content_dict).__name__, content_dict)
81
+ )
82
+ if "parts" not in content_dict:
83
+ raise ValueError(
84
+ "Missing 'parts' key in content structure at turn %s: %s"
85
+ % (turn_id, content_dict)
86
+ )
87
+ conversation_history.append(
88
+ types.evals.Message(
89
+ turn_id=str(turn_id),
90
+ content=genai_types.Content.model_validate(content_dict),
91
+ )
92
+ )
93
+ if conversation_history:
94
+ last_message = conversation_history.pop()
95
+ last_message_role = (
96
+ last_message.content.role if last_message.content else "user"
97
+ )
98
+ if last_message_role in ["user", None]:
99
+ prompt = (
100
+ last_message.content
101
+ if last_message.content
102
+ else genai_types.Content()
103
+ )
104
+ elif last_message_role == "model":
105
+ reference = types.ResponseCandidate(response=last_message.content)
106
+ if conversation_history:
107
+ second_to_last_message = conversation_history.pop()
108
+ prompt = (
109
+ second_to_last_message.content
110
+ if second_to_last_message.content
111
+ else genai_types.Content()
112
+ )
113
+ else:
114
+ prompt = genai_types.Content()
115
+
116
+ return prompt, system_instruction, conversation_history, reference
117
+
118
+ @override
119
+ def convert(self, raw_data: list[dict[str, Any]]) -> types.EvaluationDataset:
120
+ """Converts a list of raw data into an EvaluationDataset."""
121
+ eval_cases = []
122
+
123
+ for i, item in enumerate(raw_data):
124
+ eval_case_id = "gemini_eval_case_%s" % i
125
+ request_data = item.get("request", {})
126
+ response_data = item.get("response", {})
127
+
128
+ (
129
+ prompt,
130
+ system_instruction,
131
+ conversation_history,
132
+ reference,
133
+ ) = self._parse_request(request_data)
134
+
135
+ responses = []
136
+ if isinstance(response_data, str):
137
+ responses.append(
138
+ types.ResponseCandidate(
139
+ response=genai_types.Content(
140
+ parts=[genai_types.Part(text=response_data)]
141
+ )
142
+ )
143
+ )
144
+ elif isinstance(response_data, dict):
145
+ try:
146
+ generate_content_response = (
147
+ genai_types.GenerateContentResponse.model_validate(
148
+ response_data
149
+ )
150
+ )
151
+ if generate_content_response.candidates:
152
+ candidate = generate_content_response.candidates[0]
153
+ if candidate.content:
154
+ responses.append(
155
+ types.ResponseCandidate(
156
+ response=genai_types.Content.model_validate(
157
+ candidate.content
158
+ )
159
+ )
160
+ )
161
+ else:
162
+ responses.append(_create_placeholder_response_candidate())
163
+ except Exception:
164
+ responses.append(_create_placeholder_response_candidate())
165
+ else:
166
+ responses.append(_create_placeholder_response_candidate())
167
+
168
+ eval_case = types.EvalCase(
169
+ eval_case_id=eval_case_id,
170
+ prompt=prompt,
171
+ responses=responses,
172
+ reference=reference,
173
+ system_instruction=system_instruction,
174
+ conversation_history=conversation_history,
175
+ )
176
+ eval_cases.append(eval_case)
177
+
178
+ return types.EvaluationDataset(eval_cases=eval_cases)
179
+
180
+
181
+ class _FlattenEvalDataConverter(_evals_utils.EvalDataConverter):
182
+ """Converter for datasets in a structured table format."""
183
+
184
+ def convert(self, raw_data: list[dict[str, Any]]) -> types.EvaluationDataset:
185
+ """Converts a list of raw data into an EvaluationDataset."""
186
+ eval_cases = []
187
+ for i, item_dict in enumerate(raw_data):
188
+ if not isinstance(item_dict, dict):
189
+ raise TypeError(
190
+ "Expected a dictionary for item at index %s, but got %s: %s"
191
+ % (i, type(item_dict).__name__, item_dict)
192
+ )
193
+ item = copy.deepcopy(item_dict)
194
+ eval_case_id = "eval_case_%s" % i
195
+ prompt_data = item.pop("prompt", None)
196
+ if not prompt_data:
197
+ prompt_data = item.pop("source", None)
198
+
199
+ conversation_history_data = item.pop("conversation_history", None)
200
+ if conversation_history_data is None:
201
+ conversation_history_data = item.pop("history", None)
202
+ response_data = item.pop("response", None)
203
+ reference_data = item.pop("reference", None)
204
+ system_instruction_data = item.pop("instruction", None)
205
+ rubric_groups_data = item.pop("rubric_groups", None)
206
+ intermediate_events_data = item.pop("intermediate_events", None)
207
+ agent_data_raw = item.pop("agent_data", None)
208
+
209
+ if not response_data and not agent_data_raw:
210
+ raise ValueError(
211
+ "Response is required but missing for %s." % eval_case_id
212
+ )
213
+ if not prompt_data and not agent_data_raw:
214
+ raise ValueError(
215
+ "Prompt is required but missing for %s." % eval_case_id
216
+ )
217
+
218
+ prompt: Optional[genai_types.Content] = None
219
+ if isinstance(prompt_data, str):
220
+ prompt = genai_types.Content(parts=[genai_types.Part(text=prompt_data)])
221
+ elif isinstance(prompt_data, dict):
222
+ prompt = genai_types.Content.model_validate(prompt_data)
223
+ elif isinstance(prompt_data, genai_types.Content):
224
+ prompt = prompt_data
225
+ elif not agent_data_raw:
226
+ raise ValueError(
227
+ "Invalid prompt type for case %s: %s" % (i, type(prompt_data))
228
+ )
229
+
230
+ conversation_history: Optional[list[types.evals.Message]] = None
231
+ if isinstance(conversation_history_data, list):
232
+ conversation_history = []
233
+ for turn_id, content in enumerate(conversation_history_data):
234
+ if isinstance(content, genai_types.Content):
235
+ conversation_history.append(
236
+ types.evals.Message(
237
+ turn_id=str(turn_id),
238
+ content=content,
239
+ )
240
+ )
241
+ elif isinstance(content, dict):
242
+ try:
243
+ validated_content = genai_types.Content.model_validate(
244
+ content
245
+ )
246
+ conversation_history.append(
247
+ types.evals.Message(
248
+ turn_id=str(turn_id),
249
+ content=validated_content,
250
+ )
251
+ )
252
+ except ValidationError as e:
253
+ logger.warning(
254
+ "Item at index %s in 'history' column for case "
255
+ " %s is a dict but could not be validated as"
256
+ " genai_types.Content: %s",
257
+ turn_id,
258
+ eval_case_id,
259
+ e,
260
+ )
261
+ else:
262
+ logger.warning(
263
+ "Invalid type in 'history' column for case %s at index %s. "
264
+ "Expected genai_types.Content or dict, but got %s. "
265
+ "Skipping this history item.",
266
+ eval_case_id,
267
+ turn_id,
268
+ type(content),
269
+ )
270
+
271
+ responses: Optional[list[types.ResponseCandidate]] = None
272
+ if isinstance(response_data, dict):
273
+ responses = [
274
+ types.ResponseCandidate(
275
+ response=genai_types.Content.model_validate(response_data)
276
+ )
277
+ ]
278
+ elif isinstance(response_data, str):
279
+ responses = [
280
+ types.ResponseCandidate(
281
+ response=genai_types.Content(
282
+ parts=[genai_types.Part(text=response_data)]
283
+ )
284
+ )
285
+ ]
286
+ elif isinstance(response_data, genai_types.Content):
287
+ responses = [types.ResponseCandidate(response=response_data)]
288
+ elif not agent_data_raw:
289
+ raise ValueError(
290
+ "Invalid response type for case %s: %s" % (i, type(response_data))
291
+ )
292
+
293
+ reference: Optional[types.ResponseCandidate] = None
294
+ if reference_data:
295
+ if isinstance(reference_data, dict):
296
+ reference = types.ResponseCandidate(
297
+ response=genai_types.Content.model_validate(reference_data)
298
+ )
299
+ elif isinstance(reference_data, str):
300
+ reference = types.ResponseCandidate(
301
+ response=genai_types.Content(
302
+ parts=[genai_types.Part(text=reference_data)]
303
+ )
304
+ )
305
+ elif isinstance(reference_data, genai_types.Content):
306
+ reference = types.ResponseCandidate(response=reference_data)
307
+
308
+ system_instruction: Optional[genai_types.Content] = None
309
+ if system_instruction_data:
310
+ if isinstance(system_instruction_data, dict):
311
+ system_instruction = genai_types.Content.model_validate(
312
+ system_instruction_data
313
+ )
314
+ elif isinstance(system_instruction_data, str):
315
+ system_instruction = genai_types.Content(
316
+ parts=[genai_types.Part(text=system_instruction_data)]
317
+ )
318
+ elif isinstance(system_instruction_data, genai_types.Content):
319
+ system_instruction = system_instruction_data
320
+
321
+ rubric_groups: Optional[dict[str, types.RubricGroup]] = None
322
+ if rubric_groups_data:
323
+ if isinstance(rubric_groups_data, dict):
324
+ rubric_groups = {}
325
+ for key, value in rubric_groups_data.items():
326
+ if isinstance(value, list):
327
+ try:
328
+ validated_rubrics = [
329
+ (
330
+ types.evals.Rubric.model_validate(r)
331
+ if isinstance(r, dict)
332
+ else r
333
+ )
334
+ for r in value
335
+ ]
336
+ if all(
337
+ isinstance(r, types.evals.Rubric)
338
+ for r in validated_rubrics
339
+ ):
340
+ rubric_groups[key] = types.RubricGroup(
341
+ rubrics=validated_rubrics
342
+ )
343
+ else:
344
+ logger.warning(
345
+ "Invalid item type in rubric list for group '%s' in case %s.",
346
+ key,
347
+ i,
348
+ )
349
+ except Exception as e:
350
+ logger.warning(
351
+ "Failed to validate rubrics for group '%s' in case %s: %s",
352
+ key,
353
+ i,
354
+ e,
355
+ )
356
+ elif isinstance(value, types.RubricGroup):
357
+ rubric_groups[key] = value
358
+ elif isinstance(value, dict):
359
+ try:
360
+ rubric_groups[key] = types.RubricGroup.model_validate(
361
+ value
362
+ )
363
+ except Exception as e:
364
+ logger.warning(
365
+ "Failed to validate RubricGroup dict for group '%s' in case %s: %s",
366
+ key,
367
+ i,
368
+ e,
369
+ )
370
+ else:
371
+ logger.warning(
372
+ "Invalid type for rubric group '%s' in case %s."
373
+ " Expected list of rubrics, dict, or RubricGroup.",
374
+ key,
375
+ i,
376
+ )
377
+ else:
378
+ logger.warning(
379
+ "Invalid type for rubric_groups in case %s. Expected dict.",
380
+ i,
381
+ )
382
+
383
+ intermediate_events: Optional[list[types.evals.Event]] = None
384
+ if intermediate_events_data:
385
+ if isinstance(intermediate_events_data, list):
386
+ intermediate_events = []
387
+ for event in intermediate_events_data:
388
+ if isinstance(event, dict):
389
+ try:
390
+ validated_event = types.evals.Event.model_validate(
391
+ event
392
+ )
393
+ intermediate_events.append(validated_event)
394
+ except Exception as e:
395
+ logger.warning(
396
+ "Failed to validate intermediate event dict for"
397
+ " case %s: %s",
398
+ i,
399
+ e,
400
+ )
401
+ elif isinstance(event, types.evals.Event):
402
+ intermediate_events.append(event)
403
+ else:
404
+ logger.warning(
405
+ "Invalid type for intermediate_event in case"
406
+ " %s. Expected list of dicts or list of"
407
+ " types.evals.Event objects.",
408
+ i,
409
+ )
410
+ else:
411
+ logger.warning(
412
+ "Invalid type for intermediate_events in case %s. Expected"
413
+ " list of types.evals.Event objects.",
414
+ i,
415
+ )
416
+
417
+ agent_data: Optional[types.evals.AgentData] = None
418
+ if agent_data_raw:
419
+ if isinstance(agent_data_raw, str):
420
+ try:
421
+ agent_data_dict = json.loads(agent_data_raw)
422
+ agent_data = types.evals.AgentData.model_validate(
423
+ agent_data_dict
424
+ )
425
+ except json.JSONDecodeError:
426
+ logger.warning(
427
+ "Could not decode agent_data JSON string for case %s.", i
428
+ )
429
+ except ValidationError as e:
430
+ logger.warning(
431
+ "Failed to validate agent_data for case %s: %s", i, e
432
+ )
433
+ elif isinstance(agent_data_raw, dict):
434
+ try:
435
+ agent_data = types.evals.AgentData.model_validate(
436
+ agent_data_raw
437
+ )
438
+ except ValidationError as e:
439
+ logger.warning(
440
+ "Failed to validate agent_data for case %s: %s", i, e
441
+ )
442
+ elif isinstance(agent_data_raw, types.evals.AgentData):
443
+ agent_data = agent_data_raw
444
+ else:
445
+ logger.warning(
446
+ "Invalid type for agent_data in case %s. Expected str, dict"
447
+ " or types.evals.AgentData object. Got %s",
448
+ i,
449
+ type(agent_data_raw),
450
+ )
451
+
452
+ eval_case = types.EvalCase(
453
+ eval_case_id=eval_case_id,
454
+ prompt=prompt,
455
+ responses=responses,
456
+ reference=reference,
457
+ conversation_history=conversation_history,
458
+ system_instruction=system_instruction,
459
+ rubric_groups=rubric_groups,
460
+ intermediate_events=intermediate_events,
461
+ agent_data=agent_data,
462
+ **item, # Pass remaining columns as extra fields to EvalCase.
463
+ # They can be used for custom metric prompt templates.
464
+ )
465
+ eval_cases.append(eval_case)
466
+
467
+ return types.EvaluationDataset(eval_cases=eval_cases)
468
+
469
+
470
+ class _OpenAIDataConverter(_evals_utils.EvalDataConverter):
471
+ """Converter for dataset in OpenAI's Chat Completion format."""
472
+
473
+ def _parse_messages(self, messages: list[dict[str, Any]]) -> tuple[
474
+ Optional[genai_types.Content],
475
+ list[types.evals.Message],
476
+ Optional[genai_types.Content],
477
+ Optional[types.ResponseCandidate],
478
+ ]:
479
+ """Parses a list of messages into instruction, history, prompt, and reference."""
480
+ system_instruction = None
481
+ prompt = None
482
+ reference = None
483
+ conversation_history = []
484
+
485
+ if messages and messages[0].get("role") in ["system", "developer"]:
486
+ system_instruction = genai_types.Content(
487
+ parts=[genai_types.Part(text=messages[0].get("content"))]
488
+ )
489
+ messages = messages[1:]
490
+
491
+ for turn_id, msg in enumerate(messages):
492
+ role = msg.get("role", "user")
493
+ content = msg.get("content", "")
494
+ conversation_history.append(
495
+ types.evals.Message(
496
+ turn_id=str(turn_id),
497
+ content=genai_types.Content(
498
+ parts=[genai_types.Part(text=content)], role=role
499
+ ),
500
+ author=role,
501
+ )
502
+ )
503
+
504
+ if conversation_history:
505
+ last_message = conversation_history.pop()
506
+ if last_message.content and last_message.content.role == "user":
507
+ prompt = last_message.content
508
+ elif last_message.content and last_message.content.role == "assistant":
509
+ reference = types.ResponseCandidate(response=last_message.content)
510
+ if conversation_history:
511
+ second_to_last_message = conversation_history.pop()
512
+ prompt = second_to_last_message.content
513
+
514
+ return system_instruction, conversation_history, prompt, reference
515
+
516
+ @override
517
+ def convert(self, raw_data: list[dict[str, Any]]) -> types.EvaluationDataset:
518
+ """Converts a list of OpenAI ChatCompletion data into an EvaluationDataset."""
519
+ eval_cases = []
520
+ for i, item in enumerate(raw_data):
521
+ eval_case_id = "openai_eval_case_%s" % i
522
+
523
+ if "request" not in item or "response" not in item:
524
+ logger.warning(
525
+ "Skipping case %s due to missing 'request' or 'response' key.", i
526
+ )
527
+ continue
528
+
529
+ request_data = item.get("request", {})
530
+ response_data_raw = item.get("response", {})
531
+
532
+ response_data = {}
533
+ if isinstance(response_data_raw, str):
534
+ try:
535
+ loaded_json = json.loads(response_data_raw)
536
+ if isinstance(loaded_json, dict):
537
+ response_data = loaded_json
538
+ else:
539
+ logger.warning(
540
+ "Decoded response JSON is not a dictionary for case"
541
+ " %s. Type: %s",
542
+ i,
543
+ type(loaded_json),
544
+ )
545
+ except json.JSONDecodeError:
546
+ logger.warning(
547
+ "Could not decode response JSON string for case %s."
548
+ " Treating as empty response.",
549
+ i,
550
+ )
551
+ elif isinstance(response_data_raw, dict):
552
+ response_data = response_data_raw
553
+
554
+ messages = request_data.get("messages", [])
555
+ choices = response_data.get("choices", [])
556
+
557
+ (
558
+ system_instruction,
559
+ conversation_history,
560
+ prompt,
561
+ reference,
562
+ ) = self._parse_messages(messages)
563
+
564
+ if prompt is None and reference is None:
565
+ logger.warning(
566
+ "Could not determine a user prompt or reference for case %s."
567
+ " Skipping.",
568
+ i,
569
+ )
570
+ continue
571
+
572
+ responses = []
573
+ if (
574
+ choices
575
+ and isinstance(choices, list)
576
+ and isinstance(choices[0], dict)
577
+ and choices[0].get("message")
578
+ ):
579
+ response_content = choices[0]["message"].get("content", "")
580
+ responses.append(
581
+ types.ResponseCandidate(
582
+ response=genai_types.Content(
583
+ parts=[genai_types.Part(text=response_content)]
584
+ )
585
+ )
586
+ )
587
+ else:
588
+ responses.append(_create_placeholder_response_candidate())
589
+
590
+ other_fields = {
591
+ k: v for k, v in item.items() if k not in ["request", "response"]
592
+ }
593
+
594
+ eval_case = types.EvalCase(
595
+ eval_case_id=eval_case_id,
596
+ prompt=prompt,
597
+ responses=responses,
598
+ reference=reference,
599
+ system_instruction=system_instruction,
600
+ conversation_history=conversation_history,
601
+ **other_fields,
602
+ )
603
+ eval_cases.append(eval_case)
604
+
605
+ return types.EvaluationDataset(eval_cases=eval_cases)
606
+
607
+
608
+ def auto_detect_dataset_schema(
609
+ raw_dataset: list[dict[str, Any]],
610
+ ) -> Union[EvalDatasetSchema, str]:
611
+ """Detects the schema of a raw dataset."""
612
+ if not raw_dataset:
613
+ return EvalDatasetSchema.UNKNOWN
614
+
615
+ first_item = raw_dataset[0]
616
+ keys = set(first_item.keys())
617
+
618
+ if "format" in keys:
619
+ format_content = first_item.get("format", "")
620
+ if isinstance(format_content, str) and format_content == "observability":
621
+ return EvalDatasetSchema.OBSERVABILITY
622
+
623
+ if "request" in keys and "response" in keys:
624
+ request_content = first_item.get("request", {})
625
+ if isinstance(request_content, dict) and "contents" in request_content:
626
+ contents_list = request_content.get("contents")
627
+ if (
628
+ contents_list
629
+ and isinstance(contents_list, list)
630
+ and isinstance(contents_list[0], dict)
631
+ ):
632
+ if "parts" in contents_list[0]:
633
+ return EvalDatasetSchema.GEMINI
634
+
635
+ if "request" in keys and "response" in keys:
636
+ request_content = first_item.get("request", {})
637
+ if isinstance(request_content, dict) and "messages" in request_content:
638
+ messages_list = request_content.get("messages")
639
+ if (
640
+ messages_list
641
+ and isinstance(messages_list, list)
642
+ and isinstance(messages_list[0], dict)
643
+ ):
644
+ if "role" in messages_list[0] and "content" in messages_list[0]:
645
+ return EvalDatasetSchema.OPENAI
646
+
647
+ if "agent_data" in keys:
648
+ return EvalDatasetSchema.FLATTEN
649
+
650
+ if {"prompt", "response"}.issubset(keys) or {
651
+ "response",
652
+ "reference",
653
+ }.issubset(keys):
654
+ return EvalDatasetSchema.FLATTEN
655
+ else:
656
+ return EvalDatasetSchema.UNKNOWN
657
+
658
+
659
+ _CONVERTER_REGISTRY = {
660
+ EvalDatasetSchema.GEMINI: _GeminiEvalDataConverter,
661
+ EvalDatasetSchema.FLATTEN: _FlattenEvalDataConverter,
662
+ EvalDatasetSchema.OPENAI: _OpenAIDataConverter,
663
+ EvalDatasetSchema.OBSERVABILITY: _observability_data_converter.ObservabilityDataConverter,
664
+ }
665
+
666
+
667
+ def get_dataset_converter(
668
+ dataset_schema: EvalDatasetSchema,
669
+ ) -> _evals_utils.EvalDataConverter:
670
+ """Returns the appropriate dataset converter for the given schema."""
671
+ if dataset_schema in _CONVERTER_REGISTRY:
672
+ return _CONVERTER_REGISTRY[dataset_schema]() # type: ignore[abstract]
673
+ else:
674
+ raise ValueError("Unsupported dataset schema: %s" % dataset_schema)
675
+
676
+
677
+ def _get_content_text(content: genai_types.Content) -> str:
678
+ """Safely extracts text from all parts of a content.
679
+
680
+ If the content has multiple parts, text from all parts is concatenated.
681
+ If a part is not text, it is ignored. If no text parts are found,
682
+ an empty string is returned.
683
+ """
684
+ text_parts = []
685
+ if (
686
+ content
687
+ and hasattr(content, "parts")
688
+ and isinstance(content.parts, list)
689
+ and content.parts
690
+ ):
691
+ for part in content.parts:
692
+ if hasattr(part, "text") and part.text is not None:
693
+ text_parts.append(str(part.text))
694
+ return "".join(text_parts)
695
+
696
+
697
+ def _get_text_from_reference(
698
+ reference: Optional[types.ResponseCandidate],
699
+ ) -> Optional[str]:
700
+ """Safely extracts text from a reference field."""
701
+ if reference and hasattr(reference, "response") and reference.response:
702
+ return _get_content_text(reference.response)
703
+ return None
704
+
705
+
706
+ def _validate_case_consistency(
707
+ base_case: types.EvalCase,
708
+ current_case: types.EvalCase,
709
+ case_idx: int,
710
+ dataset_idx: int,
711
+ ) -> None:
712
+ """Logs warnings if prompt or reference mismatches occur."""
713
+ if base_case.prompt != current_case.prompt:
714
+ base_prompt_text_preview = _get_content_text(base_case.prompt)[:50]
715
+ current_prompt_text_preview = _get_content_text(current_case.prompt)[:50]
716
+ logger.warning(
717
+ "Prompt mismatch for case index %d between base dataset (0)"
718
+ " and dataset %d. Using prompt from base. Base prompt"
719
+ " preview: '%s...', Dataset"
720
+ " %d prompt preview: '%s...'",
721
+ case_idx,
722
+ dataset_idx,
723
+ base_prompt_text_preview,
724
+ dataset_idx,
725
+ current_prompt_text_preview,
726
+ )
727
+
728
+ base_ref_text = _get_text_from_reference(base_case.reference)
729
+ current_ref_text = _get_text_from_reference(current_case.reference)
730
+
731
+ if bool(base_case.reference) != bool(current_case.reference):
732
+ logger.warning(
733
+ "Reference presence mismatch for case index %d between base"
734
+ " dataset (0) and dataset %d. Using reference (or lack"
735
+ " thereof) from base.",
736
+ case_idx,
737
+ dataset_idx,
738
+ )
739
+ elif base_ref_text != current_ref_text:
740
+ logger.warning(
741
+ "Reference text mismatch for case index %d between base"
742
+ " dataset (0) and dataset %d. Using reference from base. "
743
+ " Base ref: '%s...', Current ref:"
744
+ " '%s...'",
745
+ case_idx,
746
+ dataset_idx,
747
+ str(base_ref_text)[:50],
748
+ str(current_ref_text)[:50],
749
+ )
750
+
751
+
752
+ def merge_evaluation_datasets(
753
+ datasets: list[types.EvaluationDataset],
754
+ agent_info: Optional[types.evals.AgentInfo] = None,
755
+ ) -> types.EvaluationDataset:
756
+ """Merges multiple EvaluationDatasets into a single EvaluationDataset.
757
+
758
+ Assumes that each dataset has responses corresponding to the same set of
759
+ prompts, in the same order. The prompt, reference, system_instruction, and
760
+ conversation_history are taken from the first dataset.
761
+ """
762
+ if not datasets:
763
+ raise ValueError("Input 'datasets' cannot be empty.")
764
+
765
+ num_expected_cases = 0
766
+ if datasets[0].eval_cases:
767
+ num_expected_cases = len(datasets[0].eval_cases)
768
+
769
+ if num_expected_cases == 0:
770
+ logger.warning(
771
+ "The first dataset has no evaluation cases. Result will be empty."
772
+ )
773
+ return types.EvaluationDataset(eval_cases=[])
774
+
775
+ for i, ds in enumerate(datasets):
776
+ current_len = len(ds.eval_cases) if ds.eval_cases else 0
777
+ if current_len != num_expected_cases:
778
+ raise ValueError(
779
+ "All datasets must have the same number of evaluation cases. "
780
+ "Base dataset (0) has %s, but dataset %s has %s."
781
+ % (num_expected_cases, i, current_len)
782
+ )
783
+
784
+ merged_eval_cases: list[types.EvalCase] = []
785
+ base_parsed_dataset = datasets[0]
786
+
787
+ for case_idx in range(num_expected_cases):
788
+ base_eval_case: types.EvalCase = (
789
+ base_parsed_dataset.eval_cases[case_idx]
790
+ if base_parsed_dataset.eval_cases
791
+ else types.EvalCase()
792
+ )
793
+ candidate_responses: list[types.ResponseCandidate] = []
794
+
795
+ if base_eval_case.responses:
796
+ candidate_responses.append(base_eval_case.responses[0])
797
+ elif base_eval_case.agent_data:
798
+ candidate_responses.append(_create_placeholder_response_candidate(""))
799
+ elif getattr(base_eval_case, "interactions_data_source", None):
800
+ # Interaction data will be resolved server-side for metric
801
+ # computation; add a placeholder without warning.
802
+ candidate_responses.append(_create_placeholder_response_candidate(""))
803
+ else:
804
+ logger.warning(
805
+ "No response or agent data found for base dataset (index 0) in case %s. "
806
+ "Adding placeholder.",
807
+ case_idx,
808
+ )
809
+ candidate_responses.append(
810
+ _create_placeholder_response_candidate(
811
+ "Missing response from base dataset (0) for case %s" % case_idx
812
+ )
813
+ )
814
+
815
+ eval_case_custom_columns = base_eval_case.model_dump(
816
+ exclude={
817
+ "eval_case_id",
818
+ "prompt",
819
+ "responses",
820
+ "reference",
821
+ "system_instruction",
822
+ "conversation_history",
823
+ "intermediate_events",
824
+ "agent_data",
825
+ "agent_info",
826
+ },
827
+ exclude_none=True,
828
+ )
829
+ for dataset_idx_offset, current_parsed_ds in enumerate(datasets[1:], start=1):
830
+ current_ds_eval_case: types.EvalCase = (
831
+ current_parsed_ds.eval_cases[case_idx]
832
+ if current_parsed_ds.eval_cases
833
+ else types.EvalCase()
834
+ )
835
+
836
+ _validate_case_consistency(
837
+ base_eval_case, current_ds_eval_case, case_idx, dataset_idx_offset
838
+ )
839
+
840
+ current_ds_extra_attrs = current_ds_eval_case.model_dump(
841
+ exclude={
842
+ "eval_case_id",
843
+ "prompt",
844
+ "responses",
845
+ "reference",
846
+ "system_instruction",
847
+ "conversation_history",
848
+ "intermediate_events",
849
+ "agent_data",
850
+ "agent_info",
851
+ },
852
+ exclude_none=True,
853
+ )
854
+ eval_case_custom_columns.update(current_ds_extra_attrs)
855
+
856
+ if current_ds_eval_case.responses:
857
+ candidate_responses.append(current_ds_eval_case.responses[0])
858
+ elif current_ds_eval_case.agent_data:
859
+ candidate_responses.append(_create_placeholder_response_candidate(""))
860
+ elif getattr(current_ds_eval_case, "interactions_data_source", None):
861
+ # Interaction data will be resolved server-side for metric
862
+ # computation; add a placeholder without warning.
863
+ candidate_responses.append(_create_placeholder_response_candidate(""))
864
+ else:
865
+ logger.warning(
866
+ "No response or agent data found for dataset %s in case %s. Adding"
867
+ " placeholder.",
868
+ dataset_idx_offset,
869
+ case_idx,
870
+ )
871
+ candidate_responses.append(
872
+ _create_placeholder_response_candidate(
873
+ "Missing response from dataset %s for case %s"
874
+ % (dataset_idx_offset, case_idx)
875
+ )
876
+ )
877
+
878
+ merged_case = types.EvalCase(
879
+ eval_case_id=base_eval_case.eval_case_id
880
+ or "merged_eval_case_%s" % case_idx,
881
+ prompt=base_eval_case.prompt,
882
+ responses=candidate_responses if candidate_responses else None,
883
+ reference=base_eval_case.reference,
884
+ system_instruction=base_eval_case.system_instruction,
885
+ conversation_history=base_eval_case.conversation_history,
886
+ agent_info=agent_info or base_eval_case.agent_info,
887
+ agent_data=base_eval_case.agent_data,
888
+ intermediate_events=base_eval_case.intermediate_events,
889
+ **eval_case_custom_columns,
890
+ )
891
+ merged_eval_cases.append(merged_case)
892
+
893
+ return types.EvaluationDataset(eval_cases=merged_eval_cases)
894
+
895
+
896
+ def merge_response_datasets_into_canonical_format(
897
+ raw_datasets: list[list[dict[str, Any]]],
898
+ schemas: list[str],
899
+ agent_info: Optional[types.evals.AgentInfo] = None,
900
+ ) -> types.EvaluationDataset:
901
+ """Merges multiple raw response datasets into a single EvaluationDataset.
902
+
903
+ Assumes that each dataset in raw_datasets has responses corresponding
904
+ to the same set of prompts, in the same order. The prompt, reference,
905
+ system_instruction, and conversation_history are taken from the first dataset.
906
+ """
907
+ if not isinstance(raw_datasets, list):
908
+ raise TypeError(
909
+ "Input 'raw_datasets' must be a list, got %s." % type(raw_datasets)
910
+ )
911
+ if not raw_datasets or not all(isinstance(ds, list) for ds in raw_datasets):
912
+ raise ValueError(
913
+ "Input 'raw_datasets' cannot be empty and must be a list of lists."
914
+ )
915
+ if not schemas or len(schemas) != len(raw_datasets):
916
+ raise ValueError(
917
+ "A list of schemas must be provided, one for each raw dataset. "
918
+ "Got %s schemas for %s datasets." % (len(schemas), len(raw_datasets))
919
+ )
920
+
921
+ parsed_evaluation_datasets: list[types.EvaluationDataset] = []
922
+ for i, (raw_ds_entry, schema) in enumerate(zip(raw_datasets, schemas)):
923
+ converter = get_dataset_converter(schema)
924
+ parsed_evaluation_datasets.append(converter.convert(raw_ds_entry))
925
+
926
+ return merge_evaluation_datasets(parsed_evaluation_datasets, agent_info)