google-cloud-agentplatform 1.165.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentplatform/__init__.py +72 -0
- agentplatform/_genai/__init__.py +43 -0
- agentplatform/_genai/_agent_engines_utils.py +2341 -0
- agentplatform/_genai/_bigquery_utils.py +49 -0
- agentplatform/_genai/_datasets_utils.py +344 -0
- agentplatform/_genai/_evals_builtin_tools.py +209 -0
- agentplatform/_genai/_evals_common.py +4268 -0
- agentplatform/_genai/_evals_constant.py +122 -0
- agentplatform/_genai/_evals_data_converters.py +926 -0
- agentplatform/_genai/_evals_metric_handlers.py +1783 -0
- agentplatform/_genai/_evals_metric_loaders.py +401 -0
- agentplatform/_genai/_evals_utils.py +1043 -0
- agentplatform/_genai/_evals_visualization.py +2070 -0
- agentplatform/_genai/_gcs_utils.py +262 -0
- agentplatform/_genai/_logging_utils.py +47 -0
- agentplatform/_genai/_memory_bank_utils.py +206 -0
- agentplatform/_genai/_observability_data_converter.py +186 -0
- agentplatform/_genai/_operations_utils.py +94 -0
- agentplatform/_genai/_prompt_management_utils.py +147 -0
- agentplatform/_genai/_prompt_optimizer_utils.py +215 -0
- agentplatform/_genai/_skills_utils.py +69 -0
- agentplatform/_genai/_transformers.py +628 -0
- agentplatform/_genai/a2a_task_events.py +509 -0
- agentplatform/_genai/a2a_tasks.py +861 -0
- agentplatform/_genai/agent_engines.py +3931 -0
- agentplatform/_genai/client.py +519 -0
- agentplatform/_genai/datasets.py +3045 -0
- agentplatform/_genai/endpoints.py +1149 -0
- agentplatform/_genai/evals.py +6883 -0
- agentplatform/_genai/example_stores.py +1445 -0
- agentplatform/_genai/feedback_contexts.py +700 -0
- agentplatform/_genai/feedback_entries.py +1644 -0
- agentplatform/_genai/live.py +64 -0
- agentplatform/_genai/live_agent_engines.py +179 -0
- agentplatform/_genai/memories.py +2962 -0
- agentplatform/_genai/memory_banks.py +1927 -0
- agentplatform/_genai/memory_revisions.py +465 -0
- agentplatform/_genai/model_garden.py +2638 -0
- agentplatform/_genai/prompt_optimizer.py +995 -0
- agentplatform/_genai/prompts.py +4515 -0
- agentplatform/_genai/rag.py +4961 -0
- agentplatform/_genai/runtime_revisions.py +1257 -0
- agentplatform/_genai/runtimes.py +78 -0
- agentplatform/_genai/sandbox_snapshots.py +1015 -0
- agentplatform/_genai/sandbox_templates.py +1088 -0
- agentplatform/_genai/sandboxes.py +1604 -0
- agentplatform/_genai/session_events.py +543 -0
- agentplatform/_genai/sessions.py +1449 -0
- agentplatform/_genai/skill_revisions.py +377 -0
- agentplatform/_genai/skills.py +1708 -0
- agentplatform/_genai/types/__init__.py +4695 -0
- agentplatform/_genai/types/agent_engines.py +16 -0
- agentplatform/_genai/types/common.py +32784 -0
- agentplatform/_genai/types/evals.py +1031 -0
- agentplatform/_genai/types/prompt_optimizer.py +107 -0
- agentplatform/_genai/types/prompts.py +107 -0
- agentplatform/version.py +17 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/METADATA +79 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/RECORD +62 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/WHEEL +5 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/licenses/LICENSE +202 -0
- google_cloud_agentplatform-1.165.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|