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,1783 @@
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
+ """Handlers for computing evaluation metrics."""
16
+
17
+ import abc
18
+ import collections
19
+ from concurrent import futures
20
+ import json
21
+ import logging
22
+ import random
23
+ import statistics
24
+ import time
25
+ from typing import Any, Callable, Generic, Optional, TypeVar, Union
26
+
27
+ from google.genai import errors as genai_errors
28
+ from google.genai import _common
29
+ from google.genai import types as genai_types
30
+ from tqdm import tqdm
31
+ from typing_extensions import override
32
+
33
+ from . import _evals_common
34
+ from . import _evals_constant
35
+ from . import _evals_utils
36
+ from . import evals
37
+ from . import types
38
+
39
+
40
+ logger = logging.getLogger(__name__)
41
+ _MAX_RETRIES = 5
42
+ # HTTP status codes that are safe to retry with backoff.
43
+ _RETRYABLE_STATUS_CODES = frozenset(
44
+ {
45
+ 408, # RequestTimeout (DEADLINE_EXCEEDED)
46
+ 409, # Conflict / Aborted (ABORTED)
47
+ 429, # TooManyRequests / ResourceExhausted (RESOURCE_EXHAUSTED)
48
+ 499, # Client Closed Request (CANCELLED)
49
+ 500, # InternalServerError (INTERNAL)
50
+ 502, # BadGateway
51
+ 503, # ServiceUnavailable (UNAVAILABLE)
52
+ 504, # GatewayTimeout (DEADLINE_EXCEEDED)
53
+ }
54
+ )
55
+
56
+ R = TypeVar("R")
57
+ T = TypeVar("T", types.Metric, types.MetricSource, types.LLMMetric)
58
+
59
+
60
+ def _call_with_retry(
61
+ fn: Callable[[], R],
62
+ metric_name: str,
63
+ ) -> R:
64
+ """Calls ``fn()`` with exponential backoff + jitter on retryable errors.
65
+
66
+ Retries up to ``_MAX_RETRIES`` times on errors whose HTTP status code is
67
+ in ``_RETRYABLE_STATUS_CODES`` (Aborted, DeadlineExceeded,
68
+ ResourceExhausted, ServiceUnavailable, Cancelled). Non-retryable errors
69
+ are re-raised immediately. If all retries are exhausted the last
70
+ exception is re-raised so the caller can decide how to handle it.
71
+
72
+ Args:
73
+ fn: A zero-argument callable that performs the API call.
74
+ metric_name: Name of the metric, used for log messages.
75
+
76
+ Returns:
77
+ The return value of ``fn()``.
78
+
79
+ Raises:
80
+ genai_errors.APIError: If all retries are exhausted or the error is
81
+ not retryable.
82
+ """
83
+ for attempt in range(_MAX_RETRIES):
84
+ try:
85
+ return fn()
86
+ except genai_errors.APIError as e:
87
+ if e.code in _RETRYABLE_STATUS_CODES:
88
+ backoff = 2**attempt + random.uniform(0, 1)
89
+ logger.warning(
90
+ "Retryable error (code=%s) on attempt %d/%d for metric"
91
+ " '%s': %s. Retrying in %.1f seconds...",
92
+ e.code,
93
+ attempt + 1,
94
+ _MAX_RETRIES,
95
+ metric_name,
96
+ e,
97
+ backoff,
98
+ )
99
+ if attempt == _MAX_RETRIES - 1:
100
+ raise
101
+ time.sleep(backoff)
102
+ else:
103
+ raise
104
+ raise genai_errors.APIError(
105
+ code=504, response_json={"message": "Retries exhausted"}
106
+ )
107
+
108
+
109
+ def _has_tool_call(events: Optional[list[Any]]) -> bool:
110
+ """Checks if any event in events has a function call."""
111
+ if not events:
112
+ return False
113
+ for event in events:
114
+ if getattr(event, "content", None) and getattr(event.content, "parts", None):
115
+ for part in event.content.parts:
116
+ if hasattr(part, "function_call") and part.function_call:
117
+ return True
118
+ return False
119
+
120
+
121
+ def _extract_text_from_content(
122
+ content: Optional[genai_types.Content], warn_property: str = "text"
123
+ ) -> Optional[str]:
124
+ """Extracts and concatenates all text parts from a Content object."""
125
+ if not content or not content.parts:
126
+ return None
127
+
128
+ text_accumulator = ""
129
+ any_text_part_found = False
130
+ non_text_part_names = []
131
+
132
+ for part_obj in content.parts:
133
+ part_dump = part_obj.model_dump(exclude={"text", "thought"})
134
+ for field_name, field_value in part_dump.items():
135
+ if field_value is not None:
136
+ if field_name not in non_text_part_names:
137
+ non_text_part_names.append(field_name)
138
+
139
+ if isinstance(part_obj.text, str):
140
+ if (
141
+ hasattr(part_obj, "thought")
142
+ and isinstance(part_obj.thought, bool)
143
+ and part_obj.thought
144
+ ):
145
+ continue
146
+ any_text_part_found = True
147
+ text_accumulator += part_obj.text
148
+
149
+ if non_text_part_names and any_text_part_found:
150
+ logger.warning(
151
+ "Warning: content contains non-text parts: %s. Returning"
152
+ " concatenated %s result from text parts. Inspect individual parts"
153
+ " for full content.",
154
+ non_text_part_names,
155
+ warn_property,
156
+ )
157
+ return text_accumulator if any_text_part_found else None
158
+
159
+
160
+ def _get_prompt_from_eval_case(
161
+ eval_case: types.EvalCase,
162
+ ) -> Optional[genai_types.Content]:
163
+ """Extracts prompt content from eval_case.prompt or starting_prompt."""
164
+ if eval_case.prompt:
165
+ return eval_case.prompt
166
+
167
+ user_scenario = getattr(eval_case, "user_scenario", None)
168
+ if user_scenario and user_scenario.starting_prompt:
169
+ return genai_types.Content(
170
+ parts=[genai_types.Part(text=user_scenario.starting_prompt)]
171
+ )
172
+
173
+ return None
174
+
175
+
176
+ def _get_response_from_eval_case(
177
+ eval_case: types.EvalCase, response_index: int, metric_name: str
178
+ ) -> Optional[genai_types.Content]:
179
+ """Extracts response content from eval_case.responses."""
180
+ response_content = None
181
+ if eval_case.responses and response_index < len(eval_case.responses):
182
+ response_content = eval_case.responses[response_index].response
183
+
184
+ return response_content
185
+
186
+
187
+ def _value_to_content_list(value: Any) -> list[genai_types.Content]:
188
+ """Converts a value to a list of Content objects."""
189
+ if isinstance(value, genai_types.Content):
190
+ return [value]
191
+ if isinstance(value, types.ResponseCandidate):
192
+ return [value.response] if value.response else []
193
+ if isinstance(value, list) and value:
194
+ if isinstance(value[0], genai_types.Content):
195
+ return value
196
+ if isinstance(value[0], types.evals.Message):
197
+ history_texts = []
198
+ for msg_obj in value:
199
+ msg_text = _extract_text_from_content(msg_obj.content)
200
+ if msg_text:
201
+ role = msg_obj.content.role or msg_obj.author or "user"
202
+ history_texts.append(f"{role}: {msg_text}")
203
+ return [
204
+ genai_types.Content(
205
+ parts=[genai_types.Part(text="\n".join(history_texts))]
206
+ )
207
+ ]
208
+ return [genai_types.Content(parts=[genai_types.Part(text=json.dumps(value))])]
209
+ if isinstance(value, dict):
210
+ return [genai_types.Content(parts=[genai_types.Part(text=json.dumps(value))])]
211
+ return [genai_types.Content(parts=[genai_types.Part(text=str(value))])]
212
+
213
+
214
+ def _get_autorater_config(metric: types.Metric) -> dict[str, Any]:
215
+ """Extracts autorater config settings from a metric."""
216
+ autorater_config: dict[str, Any] = {}
217
+ if metric.judge_model:
218
+ autorater_config["autorater_model"] = metric.judge_model
219
+ if metric.judge_model_generation_config:
220
+ autorater_config["generation_config"] = metric.judge_model_generation_config
221
+ if metric.judge_model_sampling_count:
222
+ autorater_config["sampling_count"] = metric.judge_model_sampling_count
223
+ return autorater_config
224
+
225
+
226
+ def _default_aggregate_scores(
227
+ metric_name: str,
228
+ eval_case_metric_results: list[types.EvalCaseMetricResult],
229
+ calculate_pass_rate: bool = False,
230
+ ) -> types.AggregatedMetricResult:
231
+ """Default aggregation logic using mean and standard deviation."""
232
+ scores = []
233
+ num_error = 0
234
+ num_valid = 0
235
+ num_passing = 0
236
+
237
+ for result in eval_case_metric_results:
238
+ if result.error_message is None and result.score is not None:
239
+ try:
240
+ score = float(result.score)
241
+ scores.append(score)
242
+ num_valid += 1
243
+ if calculate_pass_rate and score == 1.0:
244
+ num_passing += 1
245
+ except (ValueError, TypeError):
246
+ logger.warning(
247
+ "Could not convert score '%s' to float for metric '%s' during"
248
+ " default aggregation. Counting as error.",
249
+ result.score,
250
+ metric_name,
251
+ )
252
+ num_error += 1
253
+ else:
254
+ num_error += 1
255
+
256
+ mean_score = None
257
+ stdev_score = None
258
+ pass_rate = None
259
+
260
+ if num_valid > 0:
261
+ try:
262
+ mean_score = statistics.mean(scores)
263
+ except statistics.StatisticsError as e:
264
+ logger.warning("Could not calculate mean for %s: %s", metric_name, e)
265
+ if calculate_pass_rate:
266
+ pass_rate = num_passing / num_valid
267
+
268
+ if num_valid > 1:
269
+ try:
270
+ stdev_score = statistics.stdev(scores)
271
+ except statistics.StatisticsError as e:
272
+ logger.warning("Could not calculate stdev for %s: %s", metric_name, e)
273
+
274
+ return types.AggregatedMetricResult(
275
+ metric_name=metric_name,
276
+ num_cases_total=len(eval_case_metric_results),
277
+ num_cases_valid=num_valid,
278
+ num_cases_error=num_error,
279
+ mean_score=mean_score,
280
+ stdev_score=stdev_score,
281
+ pass_rate=pass_rate if calculate_pass_rate else None,
282
+ )
283
+
284
+
285
+ class MetricHandler(abc.ABC, Generic[T]):
286
+ """Abstract base class for metric handlers."""
287
+
288
+ def __init__(self, module: "evals.Evals", metric: T):
289
+ self.module = module
290
+ self.metric: T = metric
291
+
292
+ @property
293
+ @abc.abstractmethod
294
+ def metric_name(self) -> str:
295
+ """Returns the name of the metric polymorphically."""
296
+ raise NotImplementedError()
297
+
298
+ @abc.abstractmethod
299
+ def get_metric_result(
300
+ self, eval_case: types.EvalCase, response_index: int
301
+ ) -> types.EvalCaseMetricResult:
302
+ """Processes a single evaluation case for a specific metric."""
303
+ raise NotImplementedError()
304
+
305
+ @abc.abstractmethod
306
+ def aggregate(
307
+ self, eval_case_metric_results: list[types.EvalCaseMetricResult]
308
+ ) -> types.AggregatedMetricResult:
309
+ """Aggregates the metric results for a specific metric."""
310
+ raise NotImplementedError()
311
+
312
+
313
+ class ComputationMetricHandler(MetricHandler[types.Metric]):
314
+ """Metric handler for computation metrics."""
315
+
316
+ SUPPORTED_COMPUTATION_METRICS = frozenset(
317
+ {
318
+ "exact_match",
319
+ "bleu",
320
+ "rouge_1",
321
+ "rouge_l_sum",
322
+ "tool_call_valid",
323
+ "tool_name_match",
324
+ "tool_parameter_key_match",
325
+ "tool_parameter_kv_match",
326
+ # TODO b/423934249 - Add trajectory metrics once they are supported.
327
+ }
328
+ )
329
+
330
+ @property
331
+ def metric_name(self) -> str:
332
+ return self.metric.name or "unknown_metric"
333
+
334
+ def __init__(self, module: "evals.Evals", metric: types.Metric):
335
+ super().__init__(module=module, metric=metric)
336
+ if self.metric.name not in self.SUPPORTED_COMPUTATION_METRICS:
337
+ raise ValueError(
338
+ f"Metric '{self.metric.name}' is not supported for computation."
339
+ )
340
+
341
+ def _build_request_payload(
342
+ self, eval_case: types.EvalCase, response_index: int
343
+ ) -> dict[str, Any]:
344
+ """Builds the request parameters for evaluate instances."""
345
+ request_payload = {}
346
+
347
+ response_content = _get_response_from_eval_case(
348
+ eval_case, response_index, self.metric.name
349
+ )
350
+ prediction_text = _extract_text_from_content(response_content)
351
+
352
+ if prediction_text is None:
353
+ raise ValueError(
354
+ f"Response text missing for candidate {response_index} in eval_case"
355
+ f" {eval_case.eval_case_id or 'Unknown ID'}."
356
+ )
357
+
358
+ if (
359
+ eval_case.reference is None
360
+ or _extract_text_from_content(eval_case.reference.response) is None
361
+ ):
362
+ raise ValueError(
363
+ "Reference text missing for eval_case"
364
+ f" {eval_case.eval_case_id or 'Unknown ID'}."
365
+ )
366
+ logger.debug("eval_case: %s", eval_case)
367
+
368
+ if self.metric.name and self.metric.name.startswith("rouge"):
369
+ request_payload["rouge_input"] = {
370
+ "metric_spec": {
371
+ "rouge_type": (
372
+ "rougeLsum" if self.metric.name == "rouge_l_sum" else "rouge1"
373
+ ),
374
+ },
375
+ "instances": [
376
+ {
377
+ "prediction": prediction_text,
378
+ "reference": _extract_text_from_content(
379
+ eval_case.reference.response
380
+ ),
381
+ }
382
+ ],
383
+ }
384
+ else:
385
+ request_payload[f"{self.metric.name}_input"] = {
386
+ "metric_spec": {},
387
+ "instances": [
388
+ {
389
+ "prediction": prediction_text,
390
+ "reference": _extract_text_from_content(
391
+ eval_case.reference.response
392
+ ),
393
+ }
394
+ ],
395
+ }
396
+ logger.debug("request_payload: %s", request_payload)
397
+ return request_payload
398
+
399
+ @override
400
+ def get_metric_result(
401
+ self, eval_case: types.EvalCase, response_index: int
402
+ ) -> types.EvalCaseMetricResult:
403
+ """Processes a single evaluation case for a specific computation metric."""
404
+
405
+ metric_name = self.metric.name
406
+ logger.debug(
407
+ "ComputationMetricHandler: Processing '%s' for case: %s",
408
+ metric_name,
409
+ eval_case.model_dump(exclude_none=True),
410
+ )
411
+ response = _call_with_retry(
412
+ lambda: self.module.evaluate_instances(
413
+ metric_config=self._build_request_payload(eval_case, response_index)
414
+ ).model_dump(exclude_none=True),
415
+ metric_name,
416
+ )
417
+ logger.debug("response: %s", response)
418
+ score = None
419
+ for _, result_value in response.items():
420
+ if isinstance(result_value, dict) and result_value:
421
+ for _, metric_value in result_value.items():
422
+ if isinstance(metric_value, list) and metric_value:
423
+ score = metric_value[0]["score"]
424
+ break
425
+ logger.debug("Metric result: %s", score)
426
+ return types.EvalCaseMetricResult(
427
+ metric_name=metric_name,
428
+ score=score,
429
+ )
430
+
431
+ @override
432
+ def aggregate(
433
+ self, eval_case_metric_results: list[types.EvalCaseMetricResult]
434
+ ) -> types.AggregatedMetricResult:
435
+ """Aggregates the metric results for a computation metric."""
436
+ logger.debug("Aggregating results for computation metric: %s", self.metric.name)
437
+ return _default_aggregate_scores(self.metric.name, eval_case_metric_results)
438
+
439
+
440
+ class TranslationMetricHandler(MetricHandler[types.Metric]):
441
+ """Metric handler for translation metrics."""
442
+
443
+ SUPPORTED_TRANSLATION_METRICS = frozenset({"comet", "metricx"})
444
+
445
+ @property
446
+ def metric_name(self) -> str:
447
+ return self.metric.name or "unknown_metric"
448
+
449
+ def __init__(self, module: "evals.Evals", metric: types.Metric):
450
+ super().__init__(module=module, metric=metric)
451
+
452
+ if self.metric.name not in self.SUPPORTED_TRANSLATION_METRICS:
453
+ raise ValueError(
454
+ f"Metric '{self.metric.name}' is not supported for translation."
455
+ )
456
+
457
+ def _build_request_payload(
458
+ self, eval_case: types.EvalCase, response_index: int
459
+ ) -> dict[str, Any]:
460
+ """Builds the request parameters for evaluate instances."""
461
+ request_payload = {}
462
+ metric_input_name = f"{self.metric.name}_input"
463
+ version = None
464
+ if hasattr(self.metric, "version"):
465
+ version = self.metric.version
466
+ elif self.metric.name == "comet":
467
+ version = "COMET_22_SRC_REF"
468
+ elif self.metric.name == "metricx":
469
+ version = "METRICX_24_SRC_REF"
470
+
471
+ source_language = None
472
+ target_language = None
473
+ if hasattr(self.metric, "source_language"):
474
+ source_language = self.metric.source_language
475
+ if hasattr(self.metric, "target_language"):
476
+ target_language = self.metric.target_language
477
+
478
+ response_content = _get_response_from_eval_case(
479
+ eval_case, response_index, self.metric.name
480
+ )
481
+ prediction_text = _extract_text_from_content(response_content)
482
+ prompt_text = _extract_text_from_content(_get_prompt_from_eval_case(eval_case))
483
+
484
+ if prediction_text is None:
485
+ raise ValueError(
486
+ f"Response text missing for candidate {response_index} in eval_case"
487
+ f" {eval_case.eval_case_id or 'Unknown ID'}."
488
+ )
489
+
490
+ if (
491
+ eval_case.reference is None
492
+ or _extract_text_from_content(eval_case.reference.response) is None
493
+ ):
494
+ raise ValueError(
495
+ "Reference text missing for eval_case"
496
+ f" {eval_case.eval_case_id or 'Unknown ID'}."
497
+ )
498
+ if prompt_text is None:
499
+ raise ValueError(
500
+ "Prompt text (source for translation) missing for eval_case"
501
+ f" {eval_case.eval_case_id or 'Unknown ID'}."
502
+ )
503
+
504
+ request_payload[metric_input_name] = {
505
+ "metric_spec": {
506
+ "version": version,
507
+ "source_language": source_language,
508
+ "target_language": target_language,
509
+ },
510
+ "instance": {
511
+ "prediction": prediction_text,
512
+ "reference": _extract_text_from_content(eval_case.reference.response),
513
+ "source": prompt_text,
514
+ },
515
+ }
516
+ return request_payload
517
+
518
+ @override
519
+ def get_metric_result(
520
+ self, eval_case: types.EvalCase, response_index: int
521
+ ) -> types.EvalCaseMetricResult:
522
+ """Processes a single evaluation case for a specific translation metric."""
523
+ metric_name = self.metric.name
524
+ logger.debug(
525
+ "TranslationMetricHandler: Processing '%s' for case: %s",
526
+ metric_name,
527
+ eval_case,
528
+ )
529
+ api_response = _call_with_retry(
530
+ lambda: self.module.evaluate_instances(
531
+ metric_config=self._build_request_payload(eval_case, response_index)
532
+ ),
533
+ metric_name,
534
+ )
535
+ logger.debug("API Response: %s", api_response)
536
+
537
+ score = None
538
+ error_message = None
539
+
540
+ try:
541
+ if metric_name == "comet":
542
+ if api_response and api_response.comet_result:
543
+ score = api_response.comet_result.score
544
+ else:
545
+ logger.warning(
546
+ "Comet result missing in API response for metric '%s'."
547
+ " API response: %s",
548
+ metric_name,
549
+ (
550
+ api_response.model_dump_json(exclude_none=True)
551
+ if api_response
552
+ else "None"
553
+ ),
554
+ )
555
+ elif metric_name == "metricx":
556
+ if api_response and api_response.metricx_result:
557
+ score = api_response.metricx_result.score
558
+ else:
559
+ logger.warning(
560
+ "MetricX result missing in API response for metric '%s'."
561
+ " API response: %s",
562
+ metric_name,
563
+ (
564
+ api_response.model_dump_json(exclude_none=True)
565
+ if api_response
566
+ else "None"
567
+ ),
568
+ )
569
+ if score is None and not error_message:
570
+ logger.warning(
571
+ "Score could not be extracted for translation metric '%s'."
572
+ " API response: %s",
573
+ metric_name,
574
+ (
575
+ api_response.model_dump_json(exclude_none=True)
576
+ if api_response
577
+ else "None"
578
+ ),
579
+ )
580
+ except Exception as e: # pylint: disable=broad-exception-caught
581
+ logger.error(
582
+ "Error processing/extracting score for translation metric '%s': %s."
583
+ " API response: %s",
584
+ metric_name,
585
+ e,
586
+ (
587
+ api_response.model_dump_json(exclude_none=True)
588
+ if api_response
589
+ else "None"
590
+ ),
591
+ exc_info=True,
592
+ )
593
+ error_message = f"Error extracting score: {e}"
594
+
595
+ return types.EvalCaseMetricResult(
596
+ metric_name=metric_name,
597
+ score=score,
598
+ error_message=error_message,
599
+ )
600
+
601
+ @override
602
+ def aggregate(
603
+ self, eval_case_metric_results: list[types.EvalCaseMetricResult]
604
+ ) -> types.AggregatedMetricResult:
605
+ """Aggregates the metric results for a translation metric."""
606
+ logger.debug("Aggregating results for translation metric: %s", self.metric.name)
607
+ return _default_aggregate_scores(self.metric.name, eval_case_metric_results)
608
+
609
+
610
+ def _content_to_instance_data(
611
+ content: Optional[genai_types.Content],
612
+ ) -> Optional[types.evals.InstanceData]:
613
+ """Converts a genai_types.Content object to a types.InstanceData object."""
614
+ if not content:
615
+ return None
616
+ return types.evals.InstanceData(
617
+ contents=types.evals.InstanceDataContents(contents=[content])
618
+ )
619
+
620
+
621
+ def _eval_case_to_agent_data(
622
+ eval_case: types.EvalCase,
623
+ prompt_content: Optional[genai_types.Content] = None,
624
+ response_content: Optional[genai_types.Content] = None,
625
+ ) -> Optional[types.evals.AgentData]:
626
+ """Converts an EvalCase object to a single turn AgentData object.
627
+
628
+ If `eval_case.agent_data` is provided, it is returned directly, and
629
+ `prompt_content` and `response_content` are ignored.
630
+ """
631
+ if getattr(eval_case, "agent_data", None):
632
+ return eval_case.agent_data
633
+
634
+ if (
635
+ not eval_case.agent_info
636
+ and not eval_case.intermediate_events
637
+ and not prompt_content
638
+ and not response_content
639
+ ):
640
+ return None
641
+
642
+ agents_map = eval_case.agent_info.agents if eval_case.agent_info else None
643
+ events = []
644
+ if prompt_content:
645
+ events.append(types.evals.AgentEvent(author="user", content=prompt_content))
646
+
647
+ if eval_case.intermediate_events:
648
+ for event in eval_case.intermediate_events:
649
+ events.append(
650
+ types.evals.AgentEvent(
651
+ author=event.author,
652
+ content=event.content,
653
+ event_time=event.creation_timestamp,
654
+ )
655
+ )
656
+
657
+ if response_content:
658
+ events.append(types.evals.AgentEvent(author="model", content=response_content))
659
+
660
+ turns = (
661
+ [types.evals.ConversationTurn(turn_index=0, turn_id="turn_0", events=events)]
662
+ if events
663
+ else None
664
+ )
665
+ return types.evals.AgentData(agents=agents_map, turns=turns)
666
+
667
+
668
+ def _build_evaluation_instance(
669
+ eval_case: types.EvalCase,
670
+ response_content: Optional[genai_types.Content],
671
+ prompt_instance_data: Optional[types.evals.InstanceData] = None,
672
+ prompt_template: Optional[str] = None,
673
+ ) -> types.EvaluationInstance:
674
+ """Builds a unified EvaluationInstance. Multi-turn logic is handled by the caller."""
675
+ extracted_prompt = _get_prompt_from_eval_case(eval_case)
676
+
677
+ # 1. Use caller-provided prompt data (multi-turn) or default to simple content
678
+ if prompt_instance_data is None:
679
+ prompt_instance_data = _content_to_instance_data(extracted_prompt)
680
+
681
+ # 2. Collect placeholders for other_data
682
+ other_data_map: dict[str, Any] = {}
683
+ if hasattr(eval_case, "context") and eval_case.context:
684
+ if isinstance(eval_case.context, str):
685
+ other_data_map["context"] = types.evals.InstanceData(text=eval_case.context)
686
+ elif isinstance(eval_case.context, genai_types.Content):
687
+ other_data_map["context"] = _content_to_instance_data(eval_case.context)
688
+
689
+ # 3. Extract custom variables from LLMMetric templates
690
+ if prompt_template:
691
+ template_vars = types.PromptTemplate(text=prompt_template).variables
692
+ standard_fields = {"prompt", "response", "reference", "context", "agent_data"}
693
+ for full_path in template_vars:
694
+ # Extract the root variable (e.g. 'metadata' from 'metadata.user_id')
695
+ root_var = full_path.split(".")[0].split("[")[0]
696
+
697
+ if root_var not in standard_fields and hasattr(eval_case, root_var):
698
+ val = getattr(eval_case, root_var)
699
+ # Add the root object to other_data so the backend can traverse it
700
+ other_data_map[root_var] = types.evals.InstanceData(
701
+ contents=types.evals.InstanceDataContents(
702
+ contents=_value_to_content_list(val)
703
+ )
704
+ )
705
+
706
+ # An interactions data source is mutually exclusive with agent_data: when
707
+ # set, the backend fetches the interaction + Gemini Agent config and parses
708
+ # them into agent data server-side, so we must not also send agent_data.
709
+ interactions_data_source = getattr(eval_case, "interactions_data_source", None)
710
+ agent_data = (
711
+ None
712
+ if interactions_data_source is not None
713
+ else _eval_case_to_agent_data(eval_case, extracted_prompt, response_content)
714
+ )
715
+
716
+ return types.EvaluationInstance(
717
+ prompt=prompt_instance_data,
718
+ response=_content_to_instance_data(response_content),
719
+ reference=(
720
+ _content_to_instance_data(eval_case.reference.response)
721
+ if eval_case.reference
722
+ else None
723
+ ),
724
+ rubric_groups=eval_case.rubric_groups,
725
+ other_data=(
726
+ types.MapInstance(map_instance=other_data_map) if other_data_map else None
727
+ ),
728
+ agent_data=agent_data,
729
+ interactions_data_source=interactions_data_source,
730
+ )
731
+
732
+
733
+ class LLMMetricHandler(MetricHandler[types.LLMMetric]):
734
+ """Metric handler for LLM metrics."""
735
+
736
+ @property
737
+ def metric_name(self) -> str:
738
+ return self.metric.name or "unknown_metric"
739
+
740
+ def __init__(self, module: "evals.Evals", metric: types.LLMMetric):
741
+ super().__init__(module=module, metric=metric)
742
+
743
+ @override
744
+ def get_metric_result(
745
+ self, eval_case: types.EvalCase, response_index: int
746
+ ) -> types.EvalCaseMetricResult:
747
+ """Processes a single evaluation case using the unified backend interface."""
748
+ try:
749
+ response_content = _get_response_from_eval_case(
750
+ eval_case, response_index, self.metric_name
751
+ )
752
+ if not response_content:
753
+ raise ValueError(
754
+ f"Response content missing for candidate {response_index}."
755
+ )
756
+
757
+ instance = _build_evaluation_instance(
758
+ eval_case, response_content, prompt_template=self.metric.prompt_template
759
+ )
760
+ api_response = _call_with_retry(
761
+ lambda: self.module._evaluate_instances(
762
+ metrics=[self.metric],
763
+ instance=instance,
764
+ ),
765
+ self.metric_name,
766
+ )
767
+
768
+ if api_response and api_response.metric_results:
769
+ result = api_response.metric_results[0]
770
+ error_msg = None
771
+ if result.error and getattr(result.error, "code"):
772
+ error_msg = f"Error in metric result: {result.error}"
773
+
774
+ return types.EvalCaseMetricResult(
775
+ metric_name=self.metric_name,
776
+ score=result.score,
777
+ explanation=result.explanation,
778
+ rubric_verdicts=result.rubric_verdicts,
779
+ error_message=error_msg,
780
+ )
781
+ else:
782
+ return types.EvalCaseMetricResult(
783
+ metric_name=self.metric_name,
784
+ error_message="Metric results missing in API response.",
785
+ )
786
+
787
+ except Exception as e:
788
+ logger.error(
789
+ "Error processing metric %s for case %s.",
790
+ self.metric_name,
791
+ eval_case.eval_case_id,
792
+ exc_info=True,
793
+ )
794
+ return types.EvalCaseMetricResult(
795
+ metric_name=self.metric_name, error_message=str(e)
796
+ )
797
+
798
+ @override
799
+ def aggregate(
800
+ self, eval_case_metric_results: list[types.EvalCaseMetricResult]
801
+ ) -> types.AggregatedMetricResult:
802
+ """Aggregates the metric results for a LLM metric."""
803
+ if self.metric.aggregate_summary_fn and callable(
804
+ self.metric.aggregate_summary_fn
805
+ ):
806
+ logger.info(
807
+ "Using custom aggregate_summary_fn for metric '%s'", self.metric.name
808
+ )
809
+ try:
810
+ custom_summary_dict = self.metric.aggregate_summary_fn(
811
+ eval_case_metric_results
812
+ )
813
+ if not isinstance(custom_summary_dict, dict):
814
+ raise TypeError("aggregate_summary_fn must return a dictionary.")
815
+
816
+ num_cases_total = len(eval_case_metric_results)
817
+ num_cases_error = len(
818
+ [
819
+ result
820
+ for result in eval_case_metric_results
821
+ if result.error_message is not None
822
+ ]
823
+ )
824
+ num_cases_valid = num_cases_total - num_cases_error
825
+ required_fields = {
826
+ "num_cases_total": num_cases_total,
827
+ "num_cases_error": num_cases_error,
828
+ "num_cases_valid": num_cases_valid,
829
+ }
830
+ final_summary_dict = {**required_fields, **custom_summary_dict}
831
+
832
+ return types.AggregatedMetricResult(
833
+ metric_name=self.metric.name,
834
+ **final_summary_dict,
835
+ )
836
+ except Exception as e: # pylint: disable=broad-exception-caught
837
+ logger.error(
838
+ "Error executing custom aggregate_summary_fn for metric '%s': %s."
839
+ " Falling back to default aggregation.",
840
+ self.metric.name,
841
+ e,
842
+ exc_info=True,
843
+ )
844
+ return _default_aggregate_scores(
845
+ self.metric.name, eval_case_metric_results
846
+ )
847
+ else:
848
+ logger.debug(
849
+ "Using default aggregation for LLM metric '%s'", self.metric.name
850
+ )
851
+ return _default_aggregate_scores(self.metric.name, eval_case_metric_results)
852
+
853
+
854
+ class CustomMetricHandler(MetricHandler[types.Metric]):
855
+ """Metric handler for custom metrics."""
856
+
857
+ @property
858
+ def metric_name(self) -> str:
859
+ return self.metric.name or "unknown_metric"
860
+
861
+ def __init__(self, module: "evals.Evals", metric: types.Metric):
862
+ super().__init__(module=module, metric=metric)
863
+
864
+ if not self.metric.custom_function:
865
+ raise ValueError(
866
+ f"CustomMetricHandler for '{self.metric.name}' needs "
867
+ " Metric.custom_function to be set."
868
+ )
869
+ if not isinstance(self.metric.custom_function, Callable):
870
+ raise ValueError(
871
+ f"CustomMetricHandler for '{self.metric.name}' needs "
872
+ " Metric.custom_function to be a callable function."
873
+ )
874
+
875
+ @override
876
+ def get_metric_result(
877
+ self, eval_case: types.EvalCase, response_index: int
878
+ ) -> types.EvalCaseMetricResult:
879
+ """Processes a single evaluation case for a custom metric."""
880
+ metric_name = self.metric.name
881
+ logger.debug(
882
+ "CustomMetricHandler: Processing '%s' for case: %s",
883
+ metric_name,
884
+ eval_case.model_dump(exclude_none=True),
885
+ )
886
+
887
+ try:
888
+ response_content = _get_response_from_eval_case(
889
+ eval_case, response_index, metric_name
890
+ )
891
+ except ValueError as e:
892
+ return types.EvalCaseMetricResult(
893
+ metric_name=metric_name,
894
+ error_message=str(e),
895
+ )
896
+
897
+ if not response_content:
898
+ return types.EvalCaseMetricResult(
899
+ metric_name=metric_name,
900
+ error_message=(
901
+ f"No response found for candidate {response_index} in EvalCase"
902
+ f" {eval_case.eval_case_id}."
903
+ ),
904
+ )
905
+
906
+ instance_for_custom_fn = eval_case.model_dump(
907
+ exclude={"responses"}, mode="json", exclude_none=True
908
+ )
909
+ instance_for_custom_fn["response"] = response_content.model_dump(
910
+ mode="json", exclude_none=True
911
+ )
912
+ extracted_prompt = _get_prompt_from_eval_case(eval_case)
913
+ if extracted_prompt:
914
+ instance_for_custom_fn["prompt"] = extracted_prompt.model_dump(
915
+ mode="json", exclude_none=True
916
+ )
917
+
918
+ error_msg = None
919
+ score = None
920
+ explanation = None
921
+ try:
922
+ if self.metric.custom_function and callable(self.metric.custom_function):
923
+ custom_function_result = self.metric.custom_function(
924
+ instance_for_custom_fn
925
+ )
926
+
927
+ if isinstance(custom_function_result, types.EvalCaseMetricResult):
928
+ return custom_function_result
929
+ elif (
930
+ isinstance(custom_function_result, dict)
931
+ and "score" in custom_function_result
932
+ ):
933
+ score = custom_function_result["score"]
934
+ explanation = custom_function_result.get("explanation", None)
935
+ elif isinstance(custom_function_result, (float, int)):
936
+ score = custom_function_result
937
+ explanation = None
938
+ else:
939
+ error_msg = (
940
+ f"CustomFunctionError({self.metric.custom_function}): Returned"
941
+ f" unexpected type {type(custom_function_result)}"
942
+ )
943
+
944
+ except Exception as e: # pylint: disable=broad-exception-caught
945
+ if self.metric.custom_function and hasattr(
946
+ self.metric.custom_function, "__name__"
947
+ ):
948
+ custom_function_name = self.metric.custom_function.__name__
949
+ else:
950
+ custom_function_name = "unknown_custom_function"
951
+ error_msg = f"CustomFunctionError({custom_function_name}): {e}"
952
+ score = None
953
+ explanation = None
954
+
955
+ return types.EvalCaseMetricResult(
956
+ metric_name=self.metric.name,
957
+ score=score,
958
+ explanation=explanation,
959
+ error_message=error_msg,
960
+ )
961
+
962
+ @override
963
+ def aggregate(
964
+ self, eval_case_metric_results: list[types.EvalCaseMetricResult]
965
+ ) -> types.AggregatedMetricResult:
966
+ """Aggregates the metric results for a custom metric."""
967
+ logger.debug("Aggregating results for custom metric: %s", self.metric.name)
968
+ return _default_aggregate_scores(self.metric.name, eval_case_metric_results)
969
+
970
+
971
+ class PredefinedMetricHandler(MetricHandler[types.Metric]):
972
+ """Metric handler for predefined metrics."""
973
+
974
+ @property
975
+ def metric_name(self) -> str:
976
+ return self.metric.name or "unknown_metric"
977
+
978
+ def __init__(self, module: "evals.Evals", metric: types.Metric):
979
+ super().__init__(module=module, metric=metric)
980
+ if self.metric.name not in _evals_constant.SUPPORTED_PREDEFINED_METRICS:
981
+ raise ValueError(
982
+ f"Metric '{self.metric.name}' is not a supported predefined metric."
983
+ )
984
+ if (
985
+ self.metric.judge_model
986
+ or self.metric.judge_model_generation_config
987
+ or self.metric.judge_model_sampling_count
988
+ ):
989
+ logger.warning(
990
+ "Autorater config settings (judge_model, "
991
+ "judge_model_generation_config, judge_model_sampling_count) "
992
+ "are ignored for predefined metric '%s'.",
993
+ self.metric.name,
994
+ )
995
+
996
+ def _build_request_payload(
997
+ self, eval_case: types.EvalCase, response_index: int
998
+ ) -> dict[str, Any]:
999
+ """Builds the request parameters for evaluate instances request."""
1000
+ response_content = _get_response_from_eval_case(
1001
+ eval_case, response_index, self.metric.name
1002
+ )
1003
+
1004
+ if (
1005
+ not response_content
1006
+ and not getattr(eval_case, "agent_data", None)
1007
+ and not getattr(eval_case, "interactions_data_source", None)
1008
+ ):
1009
+ raise ValueError(
1010
+ f"Response content missing for candidate {response_index}."
1011
+ )
1012
+
1013
+ if self.metric.name == "tool_use_quality_v1":
1014
+ has_tool_call = _has_tool_call(eval_case.intermediate_events)
1015
+
1016
+ # Check agent_data for tool calls if intermediate_events is empty
1017
+ agent_data = getattr(eval_case, "agent_data", None)
1018
+ if not has_tool_call and agent_data:
1019
+ for turn in agent_data.turns or []:
1020
+ if _has_tool_call(turn.events):
1021
+ has_tool_call = True
1022
+ break
1023
+
1024
+ if not has_tool_call:
1025
+ logger.warning(
1026
+ "Metric 'tool_use_quality_v1' requires tool usage in "
1027
+ "'intermediate_events' or 'agent_data', but no tool usage was found for case %s.",
1028
+ eval_case.eval_case_id,
1029
+ )
1030
+
1031
+ extracted_prompt = _get_prompt_from_eval_case(eval_case)
1032
+ prompt_instance_data = None
1033
+ if self.metric.name and self.metric.name.startswith("multi_turn"):
1034
+ prompt_contents = [
1035
+ msg.content for msg in (eval_case.conversation_history or [])
1036
+ ]
1037
+ if extracted_prompt:
1038
+ prompt_contents.append(extracted_prompt)
1039
+ prompt_instance_data = types.evals.InstanceData(
1040
+ contents=types.evals.InstanceDataContents(contents=prompt_contents)
1041
+ )
1042
+
1043
+ instance_payload = _build_evaluation_instance(
1044
+ eval_case=eval_case,
1045
+ response_content=response_content,
1046
+ prompt_instance_data=prompt_instance_data,
1047
+ )
1048
+
1049
+ request_payload: dict[str, Any] = {
1050
+ "instance": instance_payload,
1051
+ }
1052
+
1053
+ autorater_config = _get_autorater_config(self.metric)
1054
+ if autorater_config:
1055
+ request_payload["autorater_config"] = genai_types.AutoraterConfig(
1056
+ **autorater_config
1057
+ )
1058
+
1059
+ return request_payload
1060
+
1061
+ @override
1062
+ def get_metric_result(
1063
+ self, eval_case: types.EvalCase, response_index: int
1064
+ ) -> types.EvalCaseMetricResult:
1065
+ """Processes a single evaluation case for a specific predefined metric."""
1066
+ metric_name = self.metric.name
1067
+ try:
1068
+ payload = self._build_request_payload(eval_case, response_index)
1069
+ api_response = _call_with_retry(
1070
+ lambda: self.module._evaluate_instances(
1071
+ metrics=[self.metric],
1072
+ instance=payload.get("instance"),
1073
+ autorater_config=payload.get("autorater_config"),
1074
+ ),
1075
+ metric_name,
1076
+ )
1077
+
1078
+ if (
1079
+ api_response
1080
+ and hasattr(api_response, "metric_results")
1081
+ and api_response.metric_results
1082
+ ):
1083
+ result_data = api_response.metric_results[0]
1084
+
1085
+ error_message = None
1086
+ if result_data.error and getattr(result_data.error, "code"):
1087
+ error_message = f"Error in metric result: {result_data.error}"
1088
+ return types.EvalCaseMetricResult(
1089
+ metric_name=metric_name,
1090
+ score=result_data.score,
1091
+ explanation=result_data.explanation,
1092
+ rubric_verdicts=result_data.rubric_verdicts,
1093
+ error_message=error_message,
1094
+ )
1095
+ else:
1096
+ logger.error(
1097
+ "Metric results missing in API response for predefined metric '%s'."
1098
+ " API response: %s",
1099
+ metric_name,
1100
+ (
1101
+ api_response.model_dump_json(exclude_none=True)
1102
+ if api_response
1103
+ else "None"
1104
+ ),
1105
+ )
1106
+ return types.EvalCaseMetricResult(
1107
+ metric_name=metric_name,
1108
+ error_message="Metric results missing in API response.",
1109
+ )
1110
+ except Exception as e: # pylint: disable=broad-exception-caught
1111
+ logger.error(
1112
+ "Error processing metric %s for case %s: %s",
1113
+ metric_name,
1114
+ eval_case.eval_case_id,
1115
+ e,
1116
+ exc_info=True,
1117
+ )
1118
+ return types.EvalCaseMetricResult(
1119
+ metric_name=metric_name, error_message=str(e)
1120
+ )
1121
+
1122
+ @override
1123
+ def aggregate(
1124
+ self, eval_case_metric_results: list[types.EvalCaseMetricResult]
1125
+ ) -> types.AggregatedMetricResult:
1126
+ """Aggregates the metric results for a predefined metric."""
1127
+ logger.debug("Aggregating results for predefined metric: %s", self.metric.name)
1128
+ return _default_aggregate_scores(
1129
+ self.metric.name, eval_case_metric_results, calculate_pass_rate=True
1130
+ )
1131
+
1132
+
1133
+ class CustomCodeExecutionMetricHandler(MetricHandler[types.Metric]):
1134
+ """Metric handler for custom code execution metrics."""
1135
+
1136
+ @property
1137
+ def metric_name(self) -> str:
1138
+ return self.metric.name or "unknown_metric"
1139
+
1140
+ def __init__(self, module: "evals.Evals", metric: types.Metric):
1141
+ super().__init__(module=module, metric=metric)
1142
+
1143
+ if not self.metric.remote_custom_function and not self.metric.custom_function:
1144
+ raise ValueError(
1145
+ f"CustomCodeExecutionMetricHandler for '{self.metric.name}' needs "
1146
+ " custom function to be set."
1147
+ )
1148
+
1149
+ def _build_request_payload(
1150
+ self, eval_case: types.EvalCase, response_index: int
1151
+ ) -> dict[str, Any]:
1152
+ """Builds the request parameters for evaluate instances request."""
1153
+ response_content = _get_response_from_eval_case(
1154
+ eval_case, response_index, self.metric.name
1155
+ )
1156
+
1157
+ if not response_content and not getattr(eval_case, "agent_data", None):
1158
+ raise ValueError(
1159
+ f"Response content missing for candidate {response_index}."
1160
+ )
1161
+
1162
+ reference_instance_data = None
1163
+ if eval_case.reference:
1164
+ reference_instance_data = _content_to_instance_data(
1165
+ eval_case.reference.response
1166
+ )
1167
+
1168
+ extracted_prompt = _get_prompt_from_eval_case(eval_case)
1169
+ prompt_instance_data = _content_to_instance_data(extracted_prompt)
1170
+
1171
+ instance_payload = types.EvaluationInstance(
1172
+ prompt=prompt_instance_data,
1173
+ response=_content_to_instance_data(response_content),
1174
+ reference=reference_instance_data,
1175
+ agent_data=_eval_case_to_agent_data(eval_case),
1176
+ )
1177
+
1178
+ return {
1179
+ "instance": instance_payload,
1180
+ }
1181
+
1182
+ @override
1183
+ def get_metric_result(
1184
+ self, eval_case: types.EvalCase, response_index: int
1185
+ ) -> types.EvalCaseMetricResult:
1186
+ """Processes a single evaluation case for a specific custom code execution metric."""
1187
+ metric_name = self.metric.name
1188
+ try:
1189
+ payload = self._build_request_payload(eval_case, response_index)
1190
+ api_response = _call_with_retry(
1191
+ lambda: self.module._evaluate_instances(
1192
+ metrics=[self.metric],
1193
+ instance=payload.get("instance"),
1194
+ ),
1195
+ metric_name,
1196
+ )
1197
+
1198
+ if (
1199
+ api_response
1200
+ and hasattr(api_response, "metric_results")
1201
+ and api_response.metric_results
1202
+ ):
1203
+ result_data = api_response.metric_results[0]
1204
+
1205
+ error_message = None
1206
+ if result_data.error and getattr(result_data.error, "code"):
1207
+ error_message = f"Error in metric result: {result_data.error}"
1208
+ return types.EvalCaseMetricResult(
1209
+ metric_name=metric_name,
1210
+ score=result_data.score,
1211
+ explanation=result_data.explanation,
1212
+ error_message=error_message,
1213
+ )
1214
+ else:
1215
+ logger.error(
1216
+ "Metric results missing in API response for metric '%s'."
1217
+ " API response: %s",
1218
+ metric_name,
1219
+ (
1220
+ api_response.model_dump_json(exclude_none=True)
1221
+ if api_response
1222
+ else "None"
1223
+ ),
1224
+ )
1225
+ return types.EvalCaseMetricResult(
1226
+ metric_name=metric_name,
1227
+ error_message="Metric results missing in API response.",
1228
+ )
1229
+ except Exception as e: # pylint: disable=broad-exception-caught
1230
+ logger.error(
1231
+ "Error processing metric %s for case %s",
1232
+ metric_name,
1233
+ eval_case.eval_case_id,
1234
+ exc_info=True,
1235
+ )
1236
+ return types.EvalCaseMetricResult(
1237
+ metric_name=metric_name, error_message=str(e)
1238
+ )
1239
+
1240
+ @override
1241
+ def aggregate(
1242
+ self, eval_case_metric_results: list[types.EvalCaseMetricResult]
1243
+ ) -> types.AggregatedMetricResult:
1244
+ """Aggregates the metric results for a custom code execution metric."""
1245
+ logger.debug(
1246
+ "Aggregating results for custom code execution metric: %s", self.metric.name
1247
+ )
1248
+ return _default_aggregate_scores(
1249
+ self.metric.name, eval_case_metric_results, calculate_pass_rate=True
1250
+ )
1251
+
1252
+
1253
+ class RegisteredMetricHandler(MetricHandler[types.Metric]):
1254
+ """Metric handler for registered metrics."""
1255
+
1256
+ def __init__(
1257
+ self,
1258
+ module: "evals.Evals",
1259
+ metric: types.Metric,
1260
+ ):
1261
+ if isinstance(metric, dict):
1262
+ metric = types.MetricSource(**metric)
1263
+ super().__init__(module=module, metric=metric)
1264
+
1265
+ def _build_request_payload(
1266
+ self, eval_case: types.EvalCase, response_index: int
1267
+ ) -> dict[str, Any]:
1268
+ """Builds request payload for registered metric by assembling EvaluationInstance."""
1269
+ response_content = _get_response_from_eval_case(
1270
+ eval_case, response_index, self.metric_name
1271
+ )
1272
+
1273
+ if not response_content and not getattr(eval_case, "agent_data", None):
1274
+ raise ValueError(
1275
+ f"Response content missing for candidate {response_index}."
1276
+ )
1277
+
1278
+ reference_instance_data = None
1279
+ if eval_case.reference:
1280
+ reference_instance_data = _content_to_instance_data(
1281
+ eval_case.reference.response
1282
+ )
1283
+
1284
+ extracted_prompt = _get_prompt_from_eval_case(eval_case)
1285
+ prompt_instance_data = _content_to_instance_data(extracted_prompt)
1286
+
1287
+ instance_payload = types.EvaluationInstance(
1288
+ prompt=prompt_instance_data,
1289
+ response=_content_to_instance_data(response_content),
1290
+ reference=reference_instance_data,
1291
+ rubric_groups=eval_case.rubric_groups,
1292
+ agent_data=_eval_case_to_agent_data(eval_case),
1293
+ )
1294
+
1295
+ request_payload = {
1296
+ "instance": instance_payload,
1297
+ }
1298
+ return request_payload
1299
+
1300
+ @property
1301
+ def metric_name(self) -> str:
1302
+ return self.metric.name or "unknown_metric"
1303
+
1304
+ @override
1305
+ def get_metric_result(
1306
+ self, eval_case: types.EvalCase, response_index: int
1307
+ ) -> types.EvalCaseMetricResult:
1308
+ """Processes a single evaluation case using a MetricSource reference."""
1309
+ metric_name = self.metric_name
1310
+ metric_source = types.MetricSource(
1311
+ metric_resource_name=self.metric.metric_resource_name
1312
+ )
1313
+
1314
+ try:
1315
+ payload = self._build_request_payload(eval_case, response_index)
1316
+ api_response = _call_with_retry(
1317
+ lambda: self.module._evaluate_instances(
1318
+ metric_sources=[metric_source],
1319
+ instance=payload.get("instance"),
1320
+ autorater_config=payload.get("autorater_config"),
1321
+ ),
1322
+ metric_name,
1323
+ )
1324
+
1325
+ if api_response and api_response.metric_results:
1326
+ result_data = api_response.metric_results[0]
1327
+ error_message = None
1328
+ if result_data.error and getattr(result_data.error, "code"):
1329
+ error_message = f"Error in metric result: {result_data.error}"
1330
+ return types.EvalCaseMetricResult(
1331
+ metric_name=metric_name,
1332
+ score=result_data.score,
1333
+ explanation=result_data.explanation,
1334
+ rubric_verdicts=result_data.rubric_verdicts,
1335
+ error_message=error_message,
1336
+ )
1337
+ else:
1338
+ return types.EvalCaseMetricResult(
1339
+ metric_name=metric_name,
1340
+ error_message="Metric results missing in API response.",
1341
+ )
1342
+ except Exception as e:
1343
+ return types.EvalCaseMetricResult(
1344
+ metric_name=metric_name, error_message=str(e)
1345
+ )
1346
+
1347
+ @override
1348
+ def aggregate(
1349
+ self, eval_case_metric_results: list[types.EvalCaseMetricResult]
1350
+ ) -> types.AggregatedMetricResult:
1351
+ """Aggregates the metric results for a registered metric."""
1352
+ return _default_aggregate_scores(
1353
+ self.metric_name, eval_case_metric_results, calculate_pass_rate=True
1354
+ )
1355
+
1356
+
1357
+ _METRIC_HANDLER_MAPPING = [
1358
+ (
1359
+ lambda m: (
1360
+ # Recognize the user-facing class
1361
+ isinstance(m, types.CodeExecutionMetric)
1362
+ and (hasattr(m, "custom_function") and m.custom_function)
1363
+ )
1364
+ or (hasattr(m, "remote_custom_function") and m.remote_custom_function)
1365
+ # Recognize base Metric objects that have been coerced by Pydantic
1366
+ or (
1367
+ isinstance(m, types.Metric)
1368
+ and isinstance(getattr(m, "custom_function", None), str)
1369
+ ),
1370
+ CustomCodeExecutionMetricHandler,
1371
+ ),
1372
+ (
1373
+ lambda m: m.custom_function and isinstance(m.custom_function, Callable),
1374
+ CustomMetricHandler,
1375
+ ),
1376
+ (
1377
+ lambda m: getattr(m, "metric_resource_name", None) is not None,
1378
+ RegisteredMetricHandler,
1379
+ ),
1380
+ (
1381
+ lambda m: m.name in ComputationMetricHandler.SUPPORTED_COMPUTATION_METRICS,
1382
+ ComputationMetricHandler,
1383
+ ),
1384
+ (
1385
+ lambda m: m.name in TranslationMetricHandler.SUPPORTED_TRANSLATION_METRICS,
1386
+ TranslationMetricHandler,
1387
+ ),
1388
+ (
1389
+ lambda m: m.name in _evals_constant.SUPPORTED_PREDEFINED_METRICS,
1390
+ PredefinedMetricHandler,
1391
+ ),
1392
+ (lambda m: isinstance(m, types.LLMMetric), LLMMetricHandler),
1393
+ ]
1394
+
1395
+ MetricHandlerType = TypeVar(
1396
+ "MetricHandlerType",
1397
+ ComputationMetricHandler,
1398
+ TranslationMetricHandler,
1399
+ LLMMetricHandler,
1400
+ CustomMetricHandler,
1401
+ CustomCodeExecutionMetricHandler,
1402
+ PredefinedMetricHandler,
1403
+ )
1404
+
1405
+
1406
+ def get_handler_for_metric(
1407
+ module: "evals.Evals", metric: types.Metric
1408
+ ) -> Union[MetricHandlerType, Any]:
1409
+ """Returns a metric handler for the given metric."""
1410
+ for condition, handler_class in _METRIC_HANDLER_MAPPING:
1411
+ if condition(metric): # type: ignore[no-untyped-call]
1412
+ return handler_class(module=module, metric=metric)
1413
+ raise ValueError(f"Unsupported metric: {metric.name}")
1414
+
1415
+
1416
+ def calculate_win_rates(eval_result: types.EvaluationResult) -> dict[str, Any]:
1417
+ """Calculates win/tie rates for comparison results."""
1418
+ if not eval_result.eval_case_results:
1419
+ return {}
1420
+ max_models = max(
1421
+ (
1422
+ len(case.response_candidate_results)
1423
+ for case in eval_result.eval_case_results
1424
+ if case.response_candidate_results
1425
+ ),
1426
+ default=0,
1427
+ )
1428
+ if max_models == 0:
1429
+ return {}
1430
+ stats: collections.defaultdict[str, dict[str, Any]] = collections.defaultdict(
1431
+ lambda: {"wins": [0] * max_models, "ties": 0, "valid_comparisons": 0}
1432
+ )
1433
+ for case in eval_result.eval_case_results:
1434
+ if not case.response_candidate_results:
1435
+ continue
1436
+ scores_by_metric = collections.defaultdict(list)
1437
+ for idx, candidate in enumerate(case.response_candidate_results):
1438
+ for name, res in (
1439
+ candidate.metric_results.items() if candidate.metric_results else {}
1440
+ ):
1441
+ if res.score is not None:
1442
+ scores_by_metric[name].append({"score": res.score, "cand_idx": idx})
1443
+ for name, scores in scores_by_metric.items():
1444
+ if not scores:
1445
+ continue
1446
+ stats[name]["valid_comparisons"] += 1
1447
+ max_score = max(s["score"] for s in scores)
1448
+ winners = [s["cand_idx"] for s in scores if s["score"] == max_score]
1449
+ if len(winners) == 1:
1450
+ stats[name]["wins"][winners[0]] += 1
1451
+ else:
1452
+ stats[name]["ties"] += 1
1453
+ win_rates = {}
1454
+ for name, metric_stats in stats.items():
1455
+ if metric_stats["valid_comparisons"] > 0:
1456
+ win_rates[name] = {
1457
+ "win_rates": [
1458
+ w / metric_stats["valid_comparisons"] for w in metric_stats["wins"]
1459
+ ],
1460
+ "tie_rate": metric_stats["ties"] / metric_stats["valid_comparisons"],
1461
+ }
1462
+ return win_rates
1463
+
1464
+
1465
+ def _aggregate_metric_results(
1466
+ metric_handlers: list[MetricHandler[Any]],
1467
+ eval_case_results: list[types.EvalCaseResult],
1468
+ ) -> list[types.AggregatedMetricResult]:
1469
+ """Aggregates results by calling the aggregate method of each handler."""
1470
+ aggregated_metric_results = []
1471
+ logger.info("Aggregating results per metric...")
1472
+ for handler in metric_handlers:
1473
+ metric_name = handler.metric_name
1474
+ results_for_this_metric: list[types.EvalCaseMetricResult] = []
1475
+ for case_result in eval_case_results:
1476
+ if case_result.response_candidate_results:
1477
+ for response_candidate_res in case_result.response_candidate_results:
1478
+ if (
1479
+ response_candidate_res.metric_results
1480
+ and metric_name in response_candidate_res.metric_results
1481
+ and isinstance(metric_name, str)
1482
+ ):
1483
+ results_for_this_metric.append(
1484
+ response_candidate_res.metric_results[metric_name]
1485
+ )
1486
+ if not results_for_this_metric:
1487
+ logger.warning(
1488
+ "No results found for metric '%s' to aggregate.", metric_name
1489
+ )
1490
+ continue
1491
+
1492
+ try:
1493
+ summary = handler.aggregate(results_for_this_metric)
1494
+ aggregated_metric_results.append(summary)
1495
+ except NotImplementedError:
1496
+ logger.warning(
1497
+ "Aggregation not implemented for metric handler: %s (metric: '%s')."
1498
+ " Skipping summary.",
1499
+ type(handler).__name__,
1500
+ metric_name,
1501
+ )
1502
+ except Exception as e: # pylint: disable=broad-exception-caught
1503
+ logger.error(
1504
+ "Error during aggregation for metric '%s' using handler %s: %s",
1505
+ metric_name,
1506
+ type(handler).__name__,
1507
+ e,
1508
+ exc_info=True,
1509
+ )
1510
+ aggregated_metric_results.append(
1511
+ types.AggregatedMetricResult(
1512
+ metric_name=metric_name,
1513
+ num_cases_total=len(results_for_this_metric),
1514
+ num_cases_valid=0,
1515
+ num_cases_error=len(results_for_this_metric),
1516
+ mean_score=None,
1517
+ stdev_score=None,
1518
+ )
1519
+ )
1520
+ logger.debug("Finished aggregation, returning: %s", aggregated_metric_results)
1521
+ return aggregated_metric_results
1522
+
1523
+
1524
+ class EvaluationRunConfig(_common.BaseModel):
1525
+ """Configuration for an evaluation run."""
1526
+
1527
+ evals_module: Any
1528
+ """The module to be used for the evaluation run."""
1529
+ dataset: types.EvaluationDataset
1530
+ """The dataset to be used for the evaluation run."""
1531
+ metrics: list[types.Metric]
1532
+ """The list of metrics to be used for the evaluation run."""
1533
+ num_response_candidates: int
1534
+ """The number of response candidates for the evaluation run."""
1535
+
1536
+
1537
+ def _rate_limited_get_metric_result(
1538
+ rate_limiter: _evals_utils.RateLimiter,
1539
+ handler: MetricHandler[Any],
1540
+ eval_case: types.EvalCase,
1541
+ response_index: int,
1542
+ ) -> types.EvalCaseMetricResult:
1543
+ """Wraps a handler's get_metric_result with rate limiting."""
1544
+ rate_limiter.sleep_and_advance()
1545
+ return handler.get_metric_result(eval_case, response_index)
1546
+
1547
+
1548
+ def compute_metrics_and_aggregate(
1549
+ evaluation_run_config: EvaluationRunConfig,
1550
+ evaluation_service_qps: Optional[float] = None,
1551
+ ) -> types.EvaluationResult:
1552
+ """Computes metrics and aggregates them for a given evaluation run config.
1553
+
1554
+ Args:
1555
+ evaluation_run_config: The configuration for the evaluation run.
1556
+ evaluation_service_qps: Optional QPS limit for the evaluation service.
1557
+ Defaults to _DEFAULT_EVAL_SERVICE_QPS (10). Users with higher
1558
+ quotas can increase this value.
1559
+ """
1560
+ metric_handlers = []
1561
+ all_futures = []
1562
+ results_by_case_response_metric: collections.defaultdict[
1563
+ Any, collections.defaultdict[Any, dict[Any, Any]]
1564
+ ] = collections.defaultdict(lambda: collections.defaultdict(dict))
1565
+ submission_errors = []
1566
+ execution_errors = []
1567
+ case_indices_with_errors = set()
1568
+
1569
+ if evaluation_service_qps is not None and evaluation_service_qps <= 0:
1570
+ raise ValueError("evaluation_service_qps must be a positive number.")
1571
+ qps = evaluation_service_qps or _evals_utils._DEFAULT_EVAL_SERVICE_QPS
1572
+ rate_limiter = _evals_utils.RateLimiter(rate=qps)
1573
+ logger.info("Rate limiting evaluation service requests to %.1f QPS.", qps)
1574
+
1575
+ for eval_metric in evaluation_run_config.metrics:
1576
+ metric_handlers.append(
1577
+ get_handler_for_metric(evaluation_run_config.evals_module, eval_metric)
1578
+ )
1579
+
1580
+ eval_case_count = len(evaluation_run_config.dataset.eval_cases)
1581
+ logger.info("Total number of evaluation cases: %d", eval_case_count)
1582
+ logger.info(
1583
+ "Number of response candidates: %d",
1584
+ evaluation_run_config.num_response_candidates,
1585
+ )
1586
+ total_metric_computations = (
1587
+ eval_case_count
1588
+ * len(metric_handlers)
1589
+ * evaluation_run_config.num_response_candidates
1590
+ )
1591
+ logger.info("Total number of metric computations: %d", total_metric_computations)
1592
+
1593
+ with tqdm(
1594
+ total=total_metric_computations,
1595
+ desc="Computing Metrics for Evaluation Dataset",
1596
+ ) as pbar:
1597
+ with futures.ThreadPoolExecutor(
1598
+ max_workers=_evals_common.MAX_WORKERS
1599
+ ) as executor:
1600
+ for metric_handler_instance in metric_handlers:
1601
+ for eval_case_index, eval_case in enumerate(
1602
+ evaluation_run_config.dataset.eval_cases
1603
+ ):
1604
+ num_responses = (
1605
+ len(eval_case.responses) if eval_case.responses else 0
1606
+ )
1607
+ if num_responses == 0 and (
1608
+ getattr(eval_case, "agent_data", None)
1609
+ or getattr(eval_case, "interactions_data_source", None)
1610
+ ):
1611
+ num_responses = 1
1612
+
1613
+ actual_num_candidates_for_case = min(
1614
+ evaluation_run_config.num_response_candidates,
1615
+ num_responses,
1616
+ )
1617
+ for response_index in range(actual_num_candidates_for_case):
1618
+ try:
1619
+ future = executor.submit(
1620
+ _rate_limited_get_metric_result,
1621
+ rate_limiter,
1622
+ metric_handler_instance,
1623
+ eval_case,
1624
+ response_index,
1625
+ )
1626
+ future.add_done_callback(lambda _: pbar.update(1))
1627
+ logger.debug(
1628
+ "Submitting metric computation for case %d, "
1629
+ "response %d for metric %s.",
1630
+ eval_case_index,
1631
+ response_index,
1632
+ metric_handler_instance.metric_name,
1633
+ )
1634
+ all_futures.append(
1635
+ (
1636
+ future,
1637
+ metric_handler_instance.metric_name,
1638
+ eval_case_index,
1639
+ response_index,
1640
+ )
1641
+ )
1642
+ except Exception as e: # pylint: disable=broad-exception-caught
1643
+ logger.error(
1644
+ "Error submitting metric computation for case %d, "
1645
+ "response %d for metric %s: %s",
1646
+ eval_case_index,
1647
+ response_index,
1648
+ metric_handler_instance.metric_name,
1649
+ e,
1650
+ exc_info=True,
1651
+ )
1652
+ submission_errors.append(
1653
+ (
1654
+ metric_handler_instance.metric_name,
1655
+ eval_case_index,
1656
+ response_index,
1657
+ f"Error: {e}",
1658
+ )
1659
+ )
1660
+ error_result = types.EvalCaseMetricResult(
1661
+ metric_name=metric_handler_instance.metric_name,
1662
+ error_message=f"Submission Error: {e}",
1663
+ )
1664
+ results_by_case_response_metric[eval_case_index][
1665
+ response_index
1666
+ ][metric_handler_instance.metric_name] = error_result
1667
+ case_indices_with_errors.add(eval_case_index)
1668
+ pbar.update(1)
1669
+
1670
+ for future, metric_name, eval_case_index, response_index in all_futures:
1671
+ try:
1672
+ eval_case_metric_result = future.result()
1673
+ logger.debug(
1674
+ "Successfully obtained result for metric '%s', case %d, response"
1675
+ " %d: %s.",
1676
+ metric_name,
1677
+ eval_case_index,
1678
+ response_index,
1679
+ eval_case_metric_result,
1680
+ )
1681
+ results_by_case_response_metric[eval_case_index][response_index][
1682
+ metric_name
1683
+ ] = eval_case_metric_result
1684
+ logger.debug(
1685
+ "Stored result for metric '%s', case %d, response %d.",
1686
+ metric_name,
1687
+ eval_case_index,
1688
+ response_index,
1689
+ )
1690
+ except Exception as e: # pylint: disable=broad-exception-caught
1691
+ logger.error(
1692
+ "Error executing metric '%s' for case %s, response %s: %s",
1693
+ metric_name,
1694
+ eval_case_index,
1695
+ response_index,
1696
+ e,
1697
+ exc_info=True,
1698
+ )
1699
+ error_msg = (
1700
+ f"Error executing metric '{metric_name}' for case"
1701
+ f" {eval_case_index}, response {response_index}: {e}"
1702
+ )
1703
+ execution_errors.append(
1704
+ (
1705
+ metric_name,
1706
+ eval_case_index,
1707
+ response_index,
1708
+ error_msg,
1709
+ )
1710
+ )
1711
+ case_indices_with_errors.add(eval_case_index)
1712
+ error_result = types.EvalCaseMetricResult(
1713
+ metric_name=metric_name,
1714
+ error_message=error_msg,
1715
+ )
1716
+ results_by_case_response_metric[eval_case_index][response_index][
1717
+ metric_name
1718
+ ] = error_result
1719
+
1720
+ final_eval_case_results = []
1721
+ sorted_eval_case_indices = sorted(results_by_case_response_metric.keys())
1722
+ for eval_case_index in sorted_eval_case_indices:
1723
+ per_response_results_for_this_case = results_by_case_response_metric[
1724
+ eval_case_index
1725
+ ]
1726
+
1727
+ current_response_candidate_results_list = []
1728
+ sorted_response_indices = sorted(per_response_results_for_this_case.keys())
1729
+
1730
+ for response_index in sorted_response_indices:
1731
+ metric_results_for_this_response = per_response_results_for_this_case[
1732
+ response_index
1733
+ ]
1734
+
1735
+ response_candidate_result_obj = types.ResponseCandidateResult(
1736
+ response_index=response_index,
1737
+ metric_results=metric_results_for_this_response,
1738
+ )
1739
+ current_response_candidate_results_list.append(
1740
+ response_candidate_result_obj
1741
+ )
1742
+
1743
+ if current_response_candidate_results_list:
1744
+ eval_case_result = types.EvalCaseResult(
1745
+ eval_case_index=eval_case_index,
1746
+ response_candidate_results=current_response_candidate_results_list,
1747
+ )
1748
+ final_eval_case_results.append(eval_case_result)
1749
+ elif eval_case_index in case_indices_with_errors or any(
1750
+ err_case_idx == eval_case_index
1751
+ for _, err_case_idx, _, _ in submission_errors
1752
+ ):
1753
+ logger.warning(
1754
+ "EvalCase %d had errors but no metric results were"
1755
+ " processed into the structure.",
1756
+ eval_case_index,
1757
+ )
1758
+ eval_case_result = types.EvalCaseResult(
1759
+ eval_case_index=eval_case_index,
1760
+ response_candidate_results=[],
1761
+ )
1762
+ final_eval_case_results.append(eval_case_result)
1763
+
1764
+ if submission_errors:
1765
+ logger.warning("Encountered %d submission errors.", len(submission_errors))
1766
+ logger.warning("Submission errors: %s", submission_errors)
1767
+ if execution_errors:
1768
+ logger.warning("Encountered %d execution errors.", len(execution_errors))
1769
+ logger.warning("Execution errors: %s", execution_errors)
1770
+
1771
+ aggregated_metric_results = _aggregate_metric_results(
1772
+ metric_handlers, final_eval_case_results
1773
+ )
1774
+ eval_result = types.EvaluationResult(
1775
+ eval_case_results=final_eval_case_results,
1776
+ summary_metrics=aggregated_metric_results,
1777
+ )
1778
+ if evaluation_run_config.num_response_candidates > 1:
1779
+ try:
1780
+ eval_result.win_rates = calculate_win_rates(eval_result)
1781
+ except Exception as e: # pylint: disable=broad-exception-caught
1782
+ logger.error("Error calculating win rates: %s", e, exc_info=True)
1783
+ return eval_result