Skip to content

Commit ee71344

Browse files
jsondaicopybara-github
authored andcommitted
chore: add evals data converter to vertex_genai
PiperOrigin-RevId: 768538090
1 parent d588021 commit ee71344

3 files changed

Lines changed: 261 additions & 2 deletions

File tree

tests/unit/vertexai/genai/test_evals.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,3 +1208,147 @@ def test_convert_multiple_items(self):
12081208
assert len(result_dataset.eval_cases) == 2
12091209
assert result_dataset.eval_cases[0].prompt.parts[0].text == "Item 1"
12101210
assert result_dataset.eval_cases[1].prompt.parts[0].text == "Item 2"
1211+
1212+
1213+
class TestFlattenEvalDataConverter:
1214+
"""Unit tests for the _FlattenEvalDataConverter class."""
1215+
1216+
def setup_method(self):
1217+
self.converter = _evals_data_converters._FlattenEvalDataConverter()
1218+
1219+
def test_convert_simple_prompt_response(self):
1220+
raw_data_df = pd.DataFrame(
1221+
{
1222+
"prompt": ["Hello"],
1223+
"response": ["Hi"],
1224+
}
1225+
)
1226+
raw_data = raw_data_df.to_dict(orient="records")
1227+
result_dataset = self.converter.convert(raw_data)
1228+
assert isinstance(result_dataset, vertexai_genai_types.EvaluationDataset)
1229+
assert len(result_dataset.eval_cases) == 1
1230+
eval_case = result_dataset.eval_cases[0]
1231+
1232+
assert eval_case.prompt == genai_types.Content(
1233+
parts=[genai_types.Part(text="Hello")]
1234+
)
1235+
assert len(eval_case.responses) == 1
1236+
assert eval_case.responses[0].response == genai_types.Content(
1237+
parts=[genai_types.Part(text="Hi")]
1238+
)
1239+
assert eval_case.reference is None
1240+
assert eval_case.system_instruction is None
1241+
assert eval_case.conversation_history is None
1242+
1243+
def test_convert_with_system_instruction_and_reference(self):
1244+
raw_data_df = pd.DataFrame(
1245+
{
1246+
"prompt": ["Hello"],
1247+
"response": ["Hi there!"],
1248+
"instruction": ["Be nice."],
1249+
"reference": ["Hey"],
1250+
}
1251+
)
1252+
raw_data = raw_data_df.to_dict(orient="records")
1253+
result_dataset = self.converter.convert(raw_data)
1254+
eval_case = result_dataset.eval_cases[0]
1255+
assert eval_case.system_instruction == genai_types.Content(
1256+
parts=[genai_types.Part(text="Be nice.")]
1257+
)
1258+
assert eval_case.prompt == genai_types.Content(
1259+
parts=[genai_types.Part(text="Hello")]
1260+
)
1261+
assert eval_case.reference.response == genai_types.Content(
1262+
parts=[genai_types.Part(text="Hey")]
1263+
)
1264+
1265+
def test_convert_with_conversation_history(self):
1266+
raw_data_df = pd.DataFrame(
1267+
{
1268+
"prompt": ["Current prompt"],
1269+
"response": ["A response"],
1270+
"history": [
1271+
[
1272+
{"role": "user", "parts": [{"text": "Old user msg"}]},
1273+
{"role": "model", "parts": [{"text": "Old model msg"}]},
1274+
]
1275+
],
1276+
}
1277+
)
1278+
raw_data = raw_data_df.to_dict(orient="records")
1279+
result_dataset = self.converter.convert(raw_data)
1280+
eval_case = result_dataset.eval_cases[0]
1281+
1282+
assert eval_case.prompt == genai_types.Content(
1283+
parts=[genai_types.Part(text="Current prompt")]
1284+
)
1285+
assert eval_case.reference is None
1286+
assert len(eval_case.conversation_history) == 2
1287+
assert eval_case.conversation_history[0].content.parts[0].text == "Old user msg"
1288+
assert (
1289+
eval_case.conversation_history[1].content.parts[0].text == "Old model msg"
1290+
)
1291+
1292+
def test_convert_missing_response_raises_value_error(self):
1293+
raw_data_df = pd.DataFrame({"prompt": ["Hello"]}) # Missing response
1294+
raw_data = raw_data_df.to_dict(orient="records")
1295+
with pytest.raises(
1296+
ValueError, match="Response is required but missing for eval_case_0"
1297+
):
1298+
self.converter.convert(raw_data)
1299+
1300+
def test_convert_missing_prompt_raises_value_error(self):
1301+
raw_data_df = pd.DataFrame({"response": ["Hi"]}) # Missing prompt
1302+
raw_data = raw_data_df.to_dict(orient="records")
1303+
with pytest.raises(
1304+
ValueError, match="Prompt is required but missing for eval_case_0"
1305+
):
1306+
self.converter.convert(raw_data)
1307+
1308+
def test_convert_invalid_prompt_type_raises_value_error(self):
1309+
raw_data_df = pd.DataFrame(
1310+
{
1311+
"prompt": [123], # Invalid prompt type
1312+
"response": ["Hi"],
1313+
}
1314+
)
1315+
raw_data = raw_data_df.to_dict(orient="records")
1316+
with pytest.raises(ValueError, match="Invalid prompt type for case 0"):
1317+
self.converter.convert(raw_data)
1318+
1319+
def test_convert_invalid_response_type_raises_value_error(self):
1320+
raw_data_df = pd.DataFrame(
1321+
{
1322+
"prompt": ["Hello"],
1323+
"response": [123], # Invalid response type
1324+
}
1325+
)
1326+
raw_data = raw_data_df.to_dict(orient="records")
1327+
with pytest.raises(ValueError, match="Invalid response type for case 0"):
1328+
self.converter.convert(raw_data)
1329+
1330+
def test_convert_multiple_items(self):
1331+
raw_data_df = pd.DataFrame(
1332+
{
1333+
"prompt": ["Item 1", "Item 2"],
1334+
"response": ["Resp 1", "Resp 2"],
1335+
}
1336+
)
1337+
raw_data = raw_data_df.to_dict(orient="records")
1338+
result_dataset = self.converter.convert(raw_data)
1339+
assert len(result_dataset.eval_cases) == 2
1340+
assert result_dataset.eval_cases[0].prompt.parts[0].text == "Item 1"
1341+
assert result_dataset.eval_cases[1].prompt.parts[0].text == "Item 2"
1342+
1343+
def test_convert_with_additional_columns(self):
1344+
raw_data_df = pd.DataFrame(
1345+
{
1346+
"prompt": ["Hello"],
1347+
"response": ["Hi"],
1348+
"custom_column": ["custom_value"],
1349+
}
1350+
)
1351+
raw_data = raw_data_df.to_dict(orient="records")
1352+
result_dataset = self.converter.convert(raw_data)
1353+
eval_case = result_dataset.eval_cases[0]
1354+
assert eval_case.custom_column == "custom_value"

vertexai/_genai/_evals_data_converters.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import abc
1717
import enum
1818
import logging
19-
from typing import Any
19+
from typing import Any, Optional
2020

2121
from google.genai import types as genai_types
2222
from typing_extensions import override
@@ -30,6 +30,7 @@ class _EvalDatasetSchema(enum.Enum):
3030
"""Represents the schema of an evaluation dataset."""
3131

3232
GEMINI = "gemini"
33+
FLATTEN = "flatten"
3334
UNKNOWN = "unknown"
3435

3536

@@ -141,3 +142,115 @@ def convert(self, raw_data: list[dict[str, Any]]) -> types.EvaluationDataset:
141142
eval_cases.append(eval_case)
142143

143144
return types.EvaluationDataset(eval_cases=eval_cases)
145+
146+
147+
class _FlattenEvalDataConverter(_EvalDataConverter):
148+
"""Converter for datasets in a structured table format."""
149+
150+
def convert(self, raw_data: list[dict[str, Any]]) -> types.EvaluationDataset:
151+
"""Converts a list of raw data into an EvaluationDataset."""
152+
eval_cases = []
153+
for i, item_dict in enumerate(raw_data):
154+
item = item_dict.copy()
155+
eval_case_id = f"eval_case_{i}"
156+
prompt_data = item.pop("prompt", None)
157+
if not prompt_data:
158+
prompt_data = item.pop("source", None)
159+
160+
conversation_history_data = item.pop("history", None)
161+
response_data = item.pop("response", None)
162+
reference_data = item.pop("reference", None)
163+
system_instruction_data = item.pop("instruction", None)
164+
165+
if not response_data:
166+
raise ValueError(
167+
f"Response is required but missing for {eval_case_id}."
168+
)
169+
if not prompt_data:
170+
raise ValueError(f"Prompt is required but missing for {eval_case_id}.")
171+
172+
prompt: genai_types.Content
173+
if isinstance(prompt_data, str):
174+
prompt = genai_types.Content(parts=[genai_types.Part(text=prompt_data)])
175+
elif isinstance(prompt_data, dict):
176+
prompt = genai_types.Content.model_validate(prompt_data)
177+
elif isinstance(prompt_data, genai_types.Content):
178+
prompt = prompt_data
179+
else:
180+
raise ValueError(
181+
f"Invalid prompt type for case {i}: {type(prompt_data)}"
182+
)
183+
184+
conversation_history: Optional[list[types.Message]] = None
185+
if isinstance(conversation_history_data, list):
186+
conversation_history = [
187+
types.Message(
188+
turn_id=str(turn_id),
189+
content=genai_types.Content.model_validate(content),
190+
)
191+
for turn_id, content in enumerate(conversation_history_data)
192+
]
193+
194+
responses: list[types.ResponseCandidate]
195+
if isinstance(response_data, dict):
196+
responses = [
197+
types.ResponseCandidate(
198+
response=genai_types.Content.model_validate(response_data)
199+
)
200+
]
201+
elif isinstance(response_data, str):
202+
responses = [
203+
types.ResponseCandidate(
204+
response=genai_types.Content(
205+
parts=[genai_types.Part(text=response_data)]
206+
)
207+
)
208+
]
209+
elif isinstance(response_data, genai_types.Content):
210+
responses = [types.ResponseCandidate(response=response_data)]
211+
else:
212+
raise ValueError(
213+
f"Invalid response type for case {i}: {type(response_data)}"
214+
)
215+
216+
reference: Optional[types.ResponseCandidate] = None
217+
if reference_data:
218+
if isinstance(reference_data, dict):
219+
reference = types.ResponseCandidate(
220+
response=genai_types.Content.model_validate(reference_data)
221+
)
222+
elif isinstance(reference_data, str):
223+
reference = types.ResponseCandidate(
224+
response=genai_types.Content(
225+
parts=[genai_types.Part(text=reference_data)]
226+
)
227+
)
228+
elif isinstance(reference_data, genai_types.Content):
229+
reference = types.ResponseCandidate(response=reference_data)
230+
231+
system_instruction: Optional[genai_types.Content] = None
232+
if system_instruction_data:
233+
if isinstance(system_instruction_data, dict):
234+
system_instruction = genai_types.Content.model_validate(
235+
system_instruction_data
236+
)
237+
elif isinstance(system_instruction_data, str):
238+
system_instruction = genai_types.Content(
239+
parts=[genai_types.Part(text=system_instruction_data)]
240+
)
241+
elif isinstance(system_instruction_data, genai_types.Content):
242+
system_instruction = system_instruction_data
243+
244+
eval_case = types.EvalCase(
245+
eval_case_id=eval_case_id,
246+
prompt=prompt,
247+
responses=responses,
248+
reference=reference,
249+
conversation_history=conversation_history,
250+
system_instruction=system_instruction,
251+
**item, # Pass remaining columns as extra fields to EvalCase.
252+
# They can be used for custom metric prompt templates.
253+
)
254+
eval_cases.append(eval_case)
255+
256+
return types.EvaluationDataset(eval_cases=eval_cases)

vertexai/_genai/types.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from typing import Any, Callable, ClassVar, Optional, Tuple, Union
2424
from google.genai import _common
2525
from google.genai import types as genai_types
26-
from pydantic import Field, computed_field, field_validator, model_validator
26+
from pydantic import ConfigDict, Field, computed_field, field_validator, model_validator
2727
from typing_extensions import TypedDict
2828

2929
logger = logging.getLogger("vertexai_genai.types")
@@ -1909,6 +1909,8 @@ class EvalCase(_common.BaseModel):
19091909
default=None,
19101910
description="""Unique identifier for the evaluation case.""",
19111911
)
1912+
# Allow extra fields to support custom metric prompts and stay backward compatible.
1913+
model_config = ConfigDict(frozen=True, extra="allow")
19121914

19131915

19141916
class EvalCaseDict(TypedDict, total=False):

0 commit comments

Comments
 (0)