forked from google/adk-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlocal_eval_sets_manager.py
More file actions
342 lines (296 loc) · 11.2 KB
/
Copy pathlocal_eval_sets_manager.py
File metadata and controls
342 lines (296 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import json
import logging
import os
import re
import time
from typing import Any
from typing import Optional
import uuid
from google.genai import types as genai_types
from pydantic import ValidationError
from typing_extensions import override
from ..errors.not_found_error import NotFoundError
from ._eval_sets_manager_utils import add_eval_case_to_eval_set
from ._eval_sets_manager_utils import delete_eval_case_from_eval_set
from ._eval_sets_manager_utils import get_eval_case_from_eval_set
from ._eval_sets_manager_utils import get_eval_set_from_app_and_id
from ._eval_sets_manager_utils import update_eval_case_in_eval_set
from .eval_case import EvalCase
from .eval_case import IntermediateData
from .eval_case import Invocation
from .eval_case import SessionInput
from .eval_set import EvalSet
from .eval_sets_manager import EvalSetsManager
logger = logging.getLogger("google_adk." + __name__)
_EVAL_SET_FILE_EXTENSION = ".evalset.json"
def _convert_invocation_to_pydantic_schema(
invocation_in_json_format: dict[str, Any],
) -> Invocation:
"""Converts an invocation from old json format to new Pydantic Schema."""
query = invocation_in_json_format["query"]
reference = invocation_in_json_format.get("reference", "")
expected_tool_use = []
expected_intermediate_agent_responses = []
for old_tool_use in invocation_in_json_format.get("expected_tool_use", []):
expected_tool_use.append(
genai_types.FunctionCall(
name=old_tool_use["tool_name"], args=old_tool_use["tool_input"]
)
)
for old_intermediate_response in invocation_in_json_format.get(
"expected_intermediate_agent_responses", []
):
expected_intermediate_agent_responses.append((
old_intermediate_response["author"],
[genai_types.Part.from_text(text=old_intermediate_response["text"])],
))
return Invocation(
invocation_id=str(uuid.uuid4()),
user_content=genai_types.Content(
parts=[genai_types.Part.from_text(text=query)], role="user"
),
final_response=genai_types.Content(
parts=[genai_types.Part.from_text(text=reference)], role="model"
),
intermediate_data=IntermediateData(
tool_uses=expected_tool_use,
intermediate_responses=expected_intermediate_agent_responses,
),
creation_timestamp=time.time(),
)
def convert_eval_set_to_pydantic_schema(
eval_set_id: str,
eval_set_in_json_format: list[dict[str, Any]],
) -> EvalSet:
r"""Returns a pydantic EvalSet generated from the json representation.
Args:
eval_set_id: Eval set id.
eval_set_in_json_format: Eval set specified in JSON format.
Here is a sample eval set in JSON format:
[
{
"name": "roll_17_sided_dice_twice",
"data": [
{
"query": "What can you do?",
"expected_tool_use": [],
"expected_intermediate_agent_responses": [],
"reference": "I can roll dice of different sizes and check if a number
is prime. I can also use multiple tools in parallel.\n"
},
{
"query": "Roll a 17 sided dice twice for me",
"expected_tool_use": [
{
"tool_name": "roll_die",
"tool_input": {
"sides": 17
}
},
{
"tool_name": "roll_die",
"tool_input": {
"sides": 17
}
}
],
"expected_intermediate_agent_responses": [],
"reference": "I have rolled a 17 sided die twice. The first roll was
13 and the second roll was 4.\n"
}
],
"initial_session": {
"state": {},
"app_name": "hello_world",
"user_id": "user"
}
}
]
"""
eval_cases = []
for old_eval_case in eval_set_in_json_format:
new_invocations = []
for old_invocation in old_eval_case["data"]:
new_invocations.append(
_convert_invocation_to_pydantic_schema(old_invocation)
)
session_input = None
if (
"initial_session" in old_eval_case
and len(old_eval_case["initial_session"]) > 0
):
session_input = SessionInput(
app_name=old_eval_case["initial_session"].get("app_name", ""),
user_id=old_eval_case["initial_session"].get("user_id", ""),
state=old_eval_case["initial_session"].get("state", {}),
)
new_eval_case = EvalCase(
eval_id=old_eval_case["name"],
conversation=new_invocations,
session_input=session_input,
creation_timestamp=time.time(),
)
eval_cases.append(new_eval_case)
return EvalSet(
eval_set_id=eval_set_id,
name=eval_set_id,
creation_timestamp=time.time(),
eval_cases=eval_cases,
)
def load_eval_set_from_file(
eval_set_file_path: str, eval_set_id: str
) -> EvalSet:
"""Returns an EvalSet that is read from the given file."""
with open(eval_set_file_path, "r", encoding="utf-8") as f:
content = f.read()
try:
return EvalSet.model_validate_json(content)
except ValidationError:
# We assume that the eval data was specified in the old format and try
# to convert it to the new format.
return convert_eval_set_to_pydantic_schema(
eval_set_id, json.loads(content)
)
class LocalEvalSetsManager(EvalSetsManager):
"""An EvalSets manager that stores eval sets locally on disk."""
def __init__(self, agents_dir: str):
self._agents_dir = agents_dir
@override
def get_eval_set(self, app_name: str, eval_set_id: str) -> Optional[EvalSet]:
"""Returns an EvalSet identified by an app_name and eval_set_id."""
# Load the eval set file data
try:
eval_set_file_path = self._get_eval_set_file_path(app_name, eval_set_id)
return load_eval_set_from_file(eval_set_file_path, eval_set_id)
except (FileNotFoundError, ValueError):
return None
@override
def create_eval_set(self, app_name: str, eval_set_id: str) -> EvalSet:
"""Creates and returns an empty EvalSet given the app_name and eval_set_id.
Raises:
ValueError: If Eval Set ID is not valid or an eval set already exists.
"""
# Define the file path
new_eval_set_path = self._get_eval_set_file_path(app_name, eval_set_id)
logger.info("Creating eval set file `%s`", new_eval_set_path)
if not os.path.exists(new_eval_set_path):
# Write the JSON string to the file
logger.info("Eval set file doesn't exist, we will create a new one.")
new_eval_set = EvalSet(
eval_set_id=eval_set_id,
name=eval_set_id,
eval_cases=[],
creation_timestamp=time.time(),
)
self._write_eval_set_to_path(new_eval_set_path, new_eval_set)
return new_eval_set
raise ValueError(
f"EvalSet {eval_set_id} already exists for app {app_name}."
)
@override
def list_eval_sets(self, app_name: str) -> list[str]:
"""Returns a list of EvalSets that belong to the given app_name.
Args:
app_name: The app name to list the eval sets for.
Returns:
A list of EvalSet ids.
Raises:
NotFoundError: If the eval directory for the app is not found.
"""
self._validate_id("App Name", app_name)
eval_set_file_path = os.path.join(self._agents_dir, app_name)
eval_sets = []
try:
for file in os.listdir(eval_set_file_path):
if file.endswith(_EVAL_SET_FILE_EXTENSION):
eval_sets.append(
os.path.basename(file).removesuffix(_EVAL_SET_FILE_EXTENSION)
)
return sorted(eval_sets)
except FileNotFoundError as e:
raise NotFoundError(
f"Eval directory for app `{app_name}` not found."
) from e
@override
def get_eval_case(
self, app_name: str, eval_set_id: str, eval_case_id: str
) -> Optional[EvalCase]:
"""Returns an EvalCase if found; otherwise, None."""
self._validate_id("Eval Case ID", eval_case_id)
eval_set = self.get_eval_set(app_name, eval_set_id)
if not eval_set:
return None
return get_eval_case_from_eval_set(eval_set, eval_case_id)
@override
def add_eval_case(self, app_name: str, eval_set_id: str, eval_case: EvalCase):
"""Adds the given EvalCase to an existing EvalSet identified by app_name and eval_set_id.
Raises:
NotFoundError: If the eval set is not found.
"""
eval_set = get_eval_set_from_app_and_id(self, app_name, eval_set_id)
updated_eval_set = add_eval_case_to_eval_set(eval_set, eval_case)
self._save_eval_set(app_name, eval_set_id, updated_eval_set)
@override
def update_eval_case(
self, app_name: str, eval_set_id: str, updated_eval_case: EvalCase
):
"""Updates an existing EvalCase give the app_name and eval_set_id.
Raises:
NotFoundError: If the eval set or the eval case is not found.
"""
eval_set = get_eval_set_from_app_and_id(self, app_name, eval_set_id)
updated_eval_set = update_eval_case_in_eval_set(eval_set, updated_eval_case)
self._save_eval_set(app_name, eval_set_id, updated_eval_set)
@override
def delete_eval_case(
self, app_name: str, eval_set_id: str, eval_case_id: str
):
"""Deletes the given EvalCase identified by app_name, eval_set_id and eval_case_id.
Raises:
NotFoundError: If the eval set or the eval case to delete is not found.
"""
eval_set = get_eval_set_from_app_and_id(self, app_name, eval_set_id)
updated_eval_set = delete_eval_case_from_eval_set(eval_set, eval_case_id)
self._save_eval_set(app_name, eval_set_id, updated_eval_set)
def _get_eval_set_file_path(self, app_name: str, eval_set_id: str) -> str:
self._validate_id("App Name", app_name)
self._validate_id("Eval Set ID", eval_set_id)
return os.path.join(
self._agents_dir,
app_name,
eval_set_id + _EVAL_SET_FILE_EXTENSION,
)
def _validate_id(self, id_name: str, id_value: str):
pattern = r"^[a-zA-Z0-9_\-\.]+$"
if not bool(re.fullmatch(pattern, id_value)) or ".." in id_value:
raise ValueError(
f"Invalid {id_name}. {id_name} should have the `{pattern}` format and not contain `..`",
)
def _write_eval_set_to_path(self, eval_set_path: str, eval_set: EvalSet):
os.makedirs(os.path.dirname(eval_set_path), exist_ok=True)
with open(eval_set_path, "w", encoding="utf-8") as f:
f.write(
eval_set.model_dump_json(
indent=2,
exclude_unset=True,
exclude_defaults=True,
exclude_none=True,
)
)
def _save_eval_set(self, app_name: str, eval_set_id: str, eval_set: EvalSet):
eval_set_file_path = self._get_eval_set_file_path(app_name, eval_set_id)
self._write_eval_set_to_path(eval_set_file_path, eval_set)