Skip to content

Commit 0bd05df

Browse files
ankursharmascopybara-github
authored andcommitted
feat: Add Safety evaluator metric
We add a new metric for evaluating safety of Agent's response to ADK Eval. We delegate the actual implementation to Vertex Gen AI Eval SDK, so using this metric will require GCP project. As a part of this change, we created (refactored) a simple Facade for vertex gen ai eval sdk. PiperOrigin-RevId: 778580406
1 parent 62c4a85 commit 0bd05df

File tree

10 files changed

+544
-230
lines changed

10 files changed

+544
-230
lines changed

src/google/adk/cli/cli_eval.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141

4242
TOOL_TRAJECTORY_SCORE_KEY = "tool_trajectory_avg_score"
4343
RESPONSE_MATCH_SCORE_KEY = "response_match_score"
44+
SAFETY_V1_KEY = "safety_v1"
4445
# This evaluation is not very stable.
4546
# This is always optional unless explicitly specified.
4647
RESPONSE_EVALUATION_SCORE_KEY = "response_evaluation_score"
@@ -260,6 +261,7 @@ async def run_evals(
260261
def _get_evaluator(eval_metric: EvalMetric) -> Evaluator:
261262
try:
262263
from ..evaluation.response_evaluator import ResponseEvaluator
264+
from ..evaluation.safety_evaluator import SafetyEvaluatorV1
263265
from ..evaluation.trajectory_evaluator import TrajectoryEvaluator
264266
except ModuleNotFoundError as e:
265267
raise ModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e
@@ -272,5 +274,7 @@ def _get_evaluator(eval_metric: EvalMetric) -> Evaluator:
272274
return ResponseEvaluator(
273275
threshold=eval_metric.threshold, metric_name=eval_metric.metric_name
274276
)
277+
elif eval_metric.metric_name == SAFETY_V1_KEY:
278+
return SafetyEvaluatorV1(eval_metric)
275279

276280
raise ValueError(f"Unsupported eval metric: {eval_metric}")

src/google/adk/evaluation/agent_evaluator.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
from .constants import MISSING_EVAL_DEPENDENCIES_MESSAGE
3232
from .eval_case import IntermediateData
33+
from .eval_metrics import EvalMetric
3334
from .eval_set import EvalSet
3435
from .evaluator import EvalStatus
3536
from .evaluator import EvaluationResult
@@ -46,11 +47,13 @@
4647
# This is always optional unless explicitly specified.
4748
RESPONSE_EVALUATION_SCORE_KEY = "response_evaluation_score"
4849
RESPONSE_MATCH_SCORE_KEY = "response_match_score"
50+
SAFETY_V1_KEY = "safety_v1"
4951

5052
ALLOWED_CRITERIA = [
5153
TOOL_TRAJECTORY_SCORE_KEY,
5254
RESPONSE_EVALUATION_SCORE_KEY,
5355
RESPONSE_MATCH_SCORE_KEY,
56+
SAFETY_V1_KEY,
5457
]
5558

5659

@@ -387,6 +390,7 @@ def _validate_input(eval_dataset, criteria):
387390
def _get_metric_evaluator(metric_name: str, threshold: float) -> Evaluator:
388391
try:
389392
from .response_evaluator import ResponseEvaluator
393+
from .safety_evaluator import SafetyEvaluatorV1
390394
from .trajectory_evaluator import TrajectoryEvaluator
391395
except ModuleNotFoundError as e:
392396
raise ModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e
@@ -397,6 +401,10 @@ def _get_metric_evaluator(metric_name: str, threshold: float) -> Evaluator:
397401
or metric_name == RESPONSE_EVALUATION_SCORE_KEY
398402
):
399403
return ResponseEvaluator(threshold=threshold, metric_name=metric_name)
404+
elif metric_name == SAFETY_V1_KEY:
405+
return SafetyEvaluatorV1(
406+
eval_metric=EvalMetric(threshold=threshold, metric_name=metric_name)
407+
)
400408

401409
raise ValueError(f"Unsupported eval metric: {metric_name}")
402410

src/google/adk/evaluation/response_evaluator.py

Lines changed: 18 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,34 @@
1414

1515
from __future__ import annotations
1616

17-
import os
1817
from typing import Optional
1918

20-
from google.genai import types as genai_types
21-
import pandas as pd
2219
from typing_extensions import override
23-
from vertexai import Client as VertexAiClient
2420
from vertexai import types as vertexai_types
2521

2622
from .eval_case import Invocation
2723
from .eval_metrics import EvalMetric
28-
from .evaluator import EvalStatus
2924
from .evaluator import EvaluationResult
3025
from .evaluator import Evaluator
31-
from .evaluator import PerInvocationResult
3226
from .final_response_match_v1 import RougeEvaluator
27+
from .vertex_ai_eval_facade import _VertexAiEvalFacade
3328

3429

3530
class ResponseEvaluator(Evaluator):
36-
"""Runs response evaluation for agents."""
31+
"""Evaluates Agent's responses.
32+
33+
This class supports two metrics:
34+
1) response_evaluation_score
35+
This metric evaluates how coherent agent's resposne was.
36+
37+
Value range of this metric is [1,5], with values closer to 5 more desirable.
38+
39+
2) response_match_score:
40+
This metric evaluates if agent's final response matches a golden/expected
41+
final response.
42+
43+
Value range for this metric is [0,1], with values closer to 1 more desirable.
44+
"""
3745

3846
def __init__(
3947
self,
@@ -77,80 +85,6 @@ def evaluate_invocations(
7785
actual_invocations, expected_invocations
7886
)
7987

80-
total_score = 0.0
81-
num_invocations = 0
82-
per_invocation_results = []
83-
for actual, expected in zip(actual_invocations, expected_invocations):
84-
prompt = self._get_text(expected.user_content)
85-
reference = self._get_text(expected.final_response)
86-
response = self._get_text(actual.final_response)
87-
88-
eval_case = {
89-
"prompt": prompt,
90-
"reference": reference,
91-
"response": response,
92-
}
93-
94-
eval_case_result = ResponseEvaluator._perform_eval(
95-
pd.DataFrame([eval_case]), [self._metric_name]
96-
)
97-
score = self._get_score(eval_case_result)
98-
per_invocation_results.append(
99-
PerInvocationResult(
100-
actual_invocation=actual,
101-
expected_invocation=expected,
102-
score=score,
103-
eval_status=self._get_eval_status(score),
104-
)
105-
)
106-
107-
if score:
108-
total_score += score
109-
num_invocations += 1
110-
111-
if per_invocation_results:
112-
overall_score = (
113-
total_score / num_invocations if num_invocations > 0 else None
114-
)
115-
return EvaluationResult(
116-
overall_score=overall_score,
117-
overall_eval_status=self._get_eval_status(overall_score),
118-
per_invocation_results=per_invocation_results,
119-
)
120-
121-
return EvaluationResult()
122-
123-
def _get_text(self, content: Optional[genai_types.Content]) -> str:
124-
if content and content.parts:
125-
return "\n".join([p.text for p in content.parts if p.text])
126-
127-
return ""
128-
129-
def _get_score(self, eval_result) -> Optional[float]:
130-
if eval_result and eval_result.summary_metrics:
131-
return eval_result.summary_metrics[0].mean_score
132-
133-
return None
134-
135-
def _get_eval_status(self, score: Optional[float]):
136-
if score:
137-
return (
138-
EvalStatus.PASSED if score >= self._threshold else EvalStatus.FAILED
139-
)
140-
141-
return EvalStatus.NOT_EVALUATED
142-
143-
@staticmethod
144-
def _perform_eval(dataset, metrics):
145-
"""This method hides away the call to external service.
146-
147-
Primarily helps with unit testing.
148-
"""
149-
project_id = str(os.environ.get("GOOGLE_CLOUD_PROJECT"))
150-
location = os.environ.get("GOOGLE_CLOUD_REGION")
151-
client = VertexAiClient(project=project_id, location=location)
152-
153-
return client.evals.evaluate(
154-
dataset=vertexai_types.EvaluationDataset(eval_dataset_df=dataset),
155-
metrics=metrics,
156-
)
88+
return _VertexAiEvalFacade(
89+
threshold=self._threshold, metric_name=self._metric_name
90+
).evaluate_invocations(actual_invocations, expected_invocations)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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+
from __future__ import annotations
16+
17+
from typing_extensions import override
18+
from vertexai import types as vertexai_types
19+
20+
from .eval_case import Invocation
21+
from .eval_metrics import EvalMetric
22+
from .evaluator import EvaluationResult
23+
from .evaluator import Evaluator
24+
from .vertex_ai_eval_facade import _VertexAiEvalFacade
25+
26+
27+
class SafetyEvaluatorV1(Evaluator):
28+
"""Evaluates safety (harmlessness) of an Agent's Response.
29+
30+
The class delegates the responsibility to Vertex Gen AI Eval SDK. The V1
31+
suffix in the class name is added to convey that there could be other versions
32+
of the safety metric as well, and those metrics could use a different strategy
33+
to evaluate safety.
34+
35+
Using this class requires a GCP project. Please set GOOGLE_CLOUD_PROJECT and
36+
GOOGLE_CLOUD_LOCATION in your .env file.
37+
38+
Value range of the metric is [0, 1], with values closer to 1 to be more
39+
desirable (safe).
40+
"""
41+
42+
def __init__(self, eval_metric: EvalMetric):
43+
self._eval_metric = eval_metric
44+
45+
@override
46+
def evaluate_invocations(
47+
self,
48+
actual_invocations: list[Invocation],
49+
expected_invocations: list[Invocation],
50+
) -> EvaluationResult:
51+
return _VertexAiEvalFacade(
52+
threshold=self._eval_metric.threshold,
53+
metric_name=vertexai_types.PrebuiltMetric.SAFETY,
54+
).evaluate_invocations(actual_invocations, expected_invocations)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
from __future__ import annotations
16+
17+
import os
18+
from typing import Optional
19+
20+
from google.genai import types as genai_types
21+
import pandas as pd
22+
from typing_extensions import override
23+
from vertexai import Client as VertexAiClient
24+
from vertexai import types as vertexai_types
25+
26+
from .eval_case import Invocation
27+
from .evaluator import EvalStatus
28+
from .evaluator import EvaluationResult
29+
from .evaluator import Evaluator
30+
from .evaluator import PerInvocationResult
31+
32+
_ERROR_MESSAGE_SUFFIX = """
33+
You should specify both project id and location. This metric uses Vertex Gen AI
34+
Eval SDK, and it requires google cloud credentials.
35+
36+
If using an .env file add the values there, or explicitly set in the code using
37+
the template below:
38+
39+
os.environ['GOOGLE_CLOUD_LOCATION'] = <LOCATION>
40+
os.environ['GOOGLE_CLOUD_PROJECT'] = <PROJECT ID>
41+
"""
42+
43+
44+
class _VertexAiEvalFacade(Evaluator):
45+
"""Simple facade for Vertex Gen AI Eval SDK.
46+
47+
Vertex Gen AI Eval SDK exposes quite a few metrics that are valuable for
48+
agentic evals. This class helps us to access those metrics.
49+
50+
Using this class requires a GCP project. Please set GOOGLE_CLOUD_PROJECT and
51+
GOOGLE_CLOUD_LOCATION in your .env file.
52+
"""
53+
54+
def __init__(
55+
self, threshold: float, metric_name: vertexai_types.PrebuiltMetric
56+
):
57+
self._threshold = threshold
58+
self._metric_name = metric_name
59+
60+
@override
61+
def evaluate_invocations(
62+
self,
63+
actual_invocations: list[Invocation],
64+
expected_invocations: list[Invocation],
65+
) -> EvaluationResult:
66+
total_score = 0.0
67+
num_invocations = 0
68+
per_invocation_results = []
69+
for actual, expected in zip(actual_invocations, expected_invocations):
70+
prompt = self._get_text(expected.user_content)
71+
reference = self._get_text(expected.final_response)
72+
response = self._get_text(actual.final_response)
73+
eval_case = {
74+
"prompt": prompt,
75+
"reference": reference,
76+
"response": response,
77+
}
78+
79+
eval_case_result = _VertexAiEvalFacade._perform_eval(
80+
dataset=pd.DataFrame([eval_case]), metrics=[self._metric_name]
81+
)
82+
score = self._get_score(eval_case_result)
83+
per_invocation_results.append(
84+
PerInvocationResult(
85+
actual_invocation=actual,
86+
expected_invocation=expected,
87+
score=score,
88+
eval_status=self._get_eval_status(score),
89+
)
90+
)
91+
92+
if score:
93+
total_score += score
94+
num_invocations += 1
95+
96+
if per_invocation_results:
97+
overall_score = (
98+
total_score / num_invocations if num_invocations > 0 else None
99+
)
100+
return EvaluationResult(
101+
overall_score=overall_score,
102+
overall_eval_status=self._get_eval_status(overall_score),
103+
per_invocation_results=per_invocation_results,
104+
)
105+
106+
return EvaluationResult()
107+
108+
def _get_text(self, content: Optional[genai_types.Content]) -> str:
109+
if content and content.parts:
110+
return "\n".join([p.text for p in content.parts if p.text])
111+
112+
return ""
113+
114+
def _get_score(self, eval_result) -> Optional[float]:
115+
if eval_result and eval_result.summary_metrics:
116+
return eval_result.summary_metrics[0].mean_score
117+
118+
return None
119+
120+
def _get_eval_status(self, score: Optional[float]):
121+
if score:
122+
return (
123+
EvalStatus.PASSED if score >= self._threshold else EvalStatus.FAILED
124+
)
125+
126+
return EvalStatus.NOT_EVALUATED
127+
128+
@staticmethod
129+
def _perform_eval(dataset, metrics):
130+
"""This method hides away the call to external service.
131+
132+
Primarily helps with unit testing.
133+
"""
134+
project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", None)
135+
location = os.environ.get("GOOGLE_CLOUD_LOCATION", None)
136+
137+
if not project_id:
138+
raise ValueError("Missing project id." + _ERROR_MESSAGE_SUFFIX)
139+
if not location:
140+
raise ValueError("Missing location." + _ERROR_MESSAGE_SUFFIX)
141+
142+
client = VertexAiClient(project=project_id, location=location)
143+
144+
return client.evals.evaluate(
145+
dataset=vertexai_types.EvaluationDataset(eval_dataset_df=dataset),
146+
metrics=metrics,
147+
)

0 commit comments

Comments
 (0)