|
16 | 16 |
|
17 | 17 | from __future__ import annotations |
18 | 18 |
|
| 19 | +import os |
19 | 20 | from types import SimpleNamespace |
20 | 21 |
|
21 | 22 | from google.adk.agents.base_agent import BaseAgent |
22 | 23 | from google.adk.apps.app import App |
23 | 24 | from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService |
| 25 | +from google.adk.evaluation.agent_evaluator import _EvalMetricResultWithInvocation |
24 | 26 | from google.adk.evaluation.agent_evaluator import AgentEvaluator |
25 | 27 | from google.adk.evaluation.eval_case import EvalCase |
| 28 | +from google.adk.evaluation.eval_case import Invocation |
26 | 29 | from google.adk.evaluation.eval_config import EvalConfig |
| 30 | +from google.adk.evaluation.eval_metrics import EvalMetricResult |
27 | 31 | from google.adk.evaluation.eval_set import EvalSet |
| 32 | +from google.adk.evaluation.evaluator import EvalStatus |
28 | 33 | from google.adk.evaluation.simulation.user_simulator_provider import UserSimulatorProvider |
| 34 | +from google.genai import types as genai_types |
| 35 | +import pandas as pd |
29 | 36 | import pytest |
30 | 37 |
|
31 | 38 |
|
@@ -258,3 +265,175 @@ async def test_none_app_is_forwarded_by_default(self, mocker): |
258 | 265 | ) |
259 | 266 |
|
260 | 267 | assert mock_service_cls.call_args.kwargs["app"] is None |
| 268 | + |
| 269 | + |
| 270 | +def _content(text: str) -> genai_types.Content: |
| 271 | + return genai_types.Content(parts=[genai_types.Part(text=text)]) |
| 272 | + |
| 273 | + |
| 274 | +def _make_result_with_invocation( |
| 275 | + metric_name: str, |
| 276 | + score: float, |
| 277 | + threshold: float, |
| 278 | + eval_status: EvalStatus, |
| 279 | + prompt: str, |
| 280 | + expected_response: str, |
| 281 | + actual_response: str, |
| 282 | +) -> _EvalMetricResultWithInvocation: |
| 283 | + return _EvalMetricResultWithInvocation( |
| 284 | + actual_invocation=Invocation( |
| 285 | + user_content=_content(prompt), |
| 286 | + final_response=_content(actual_response), |
| 287 | + ), |
| 288 | + expected_invocation=Invocation( |
| 289 | + user_content=_content(prompt), |
| 290 | + final_response=_content(expected_response), |
| 291 | + ), |
| 292 | + eval_metric_result=EvalMetricResult( |
| 293 | + metric_name=metric_name, |
| 294 | + threshold=threshold, |
| 295 | + score=score, |
| 296 | + eval_status=eval_status, |
| 297 | + ), |
| 298 | + ) |
| 299 | + |
| 300 | + |
| 301 | +def test_get_results_as_rows_flattens_metrics_and_invocations(): |
| 302 | + eval_metric_results = { |
| 303 | + "response_match_score": [ |
| 304 | + _make_result_with_invocation( |
| 305 | + metric_name="response_match_score", |
| 306 | + score=1.0, |
| 307 | + threshold=0.8, |
| 308 | + eval_status=EvalStatus.PASSED, |
| 309 | + prompt="What is 2 + 2?", |
| 310 | + expected_response="4", |
| 311 | + actual_response="4", |
| 312 | + ), |
| 313 | + _make_result_with_invocation( |
| 314 | + metric_name="response_match_score", |
| 315 | + score=0.0, |
| 316 | + threshold=0.8, |
| 317 | + eval_status=EvalStatus.FAILED, |
| 318 | + prompt="Capital of France?", |
| 319 | + expected_response="Paris", |
| 320 | + actual_response="London", |
| 321 | + ), |
| 322 | + ], |
| 323 | + } |
| 324 | + |
| 325 | + rows = AgentEvaluator._get_results_as_rows( |
| 326 | + eval_set_id="my_eval_set", |
| 327 | + eval_id="my_eval_case", |
| 328 | + eval_metric_results=eval_metric_results, |
| 329 | + ) |
| 330 | + |
| 331 | + assert len(rows) == 2 |
| 332 | + first = rows[0] |
| 333 | + assert first["eval_set_id"] == "my_eval_set" |
| 334 | + assert first["eval_id"] == "my_eval_case" |
| 335 | + assert first["metric_name"] == "response_match_score" |
| 336 | + assert first["threshold"] == 0.8 |
| 337 | + assert first["score"] == 1.0 |
| 338 | + assert first["eval_status"] == "PASSED" |
| 339 | + assert first["prompt"] == "What is 2 + 2?" |
| 340 | + assert first["expected_response"] == "4" |
| 341 | + assert first["actual_response"] == "4" |
| 342 | + |
| 343 | + # Failing invocation should still be captured. |
| 344 | + assert rows[1]["eval_status"] == "FAILED" |
| 345 | + assert rows[1]["actual_response"] == "London" |
| 346 | + |
| 347 | + |
| 348 | +def test_get_results_as_rows_handles_missing_expected_invocation(): |
| 349 | + result = _EvalMetricResultWithInvocation( |
| 350 | + actual_invocation=Invocation( |
| 351 | + user_content=_content("hi"), |
| 352 | + final_response=_content("hello"), |
| 353 | + ), |
| 354 | + expected_invocation=None, |
| 355 | + eval_metric_result=EvalMetricResult( |
| 356 | + metric_name="safety_v1", |
| 357 | + threshold=0.5, |
| 358 | + score=1.0, |
| 359 | + eval_status=EvalStatus.PASSED, |
| 360 | + ), |
| 361 | + ) |
| 362 | + |
| 363 | + rows = AgentEvaluator._get_results_as_rows( |
| 364 | + eval_set_id="s", |
| 365 | + eval_id="c", |
| 366 | + eval_metric_results={"safety_v1": [result]}, |
| 367 | + ) |
| 368 | + |
| 369 | + assert len(rows) == 1 |
| 370 | + assert rows[0]["prompt"] == "hi" |
| 371 | + assert rows[0]["expected_response"] == "" |
| 372 | + assert rows[0]["actual_response"] == "hello" |
| 373 | + |
| 374 | + |
| 375 | +def test_write_results_to_csv_writes_expected_file(tmp_path): |
| 376 | + rows = [ |
| 377 | + { |
| 378 | + "eval_set_id": "s", |
| 379 | + "eval_id": "c", |
| 380 | + "metric_name": "response_match_score", |
| 381 | + "threshold": 0.8, |
| 382 | + "score": 1.0, |
| 383 | + "eval_status": "PASSED", |
| 384 | + "prompt": "What is 2 + 2?", |
| 385 | + "expected_response": "4", |
| 386 | + "actual_response": "4", |
| 387 | + "expected_tool_calls": "", |
| 388 | + "actual_tool_calls": "", |
| 389 | + }, |
| 390 | + ] |
| 391 | + output_file = os.path.join(str(tmp_path), "nested", "eval_results.csv") |
| 392 | + |
| 393 | + AgentEvaluator._write_results_to_csv(rows=rows, output_file=output_file) |
| 394 | + |
| 395 | + # The nested directory should have been created. |
| 396 | + assert os.path.isfile(output_file) |
| 397 | + |
| 398 | + df = pd.read_csv(output_file) |
| 399 | + assert list(df.columns) == list(rows[0].keys()) |
| 400 | + assert len(df) == 1 |
| 401 | + assert df.iloc[0]["metric_name"] == "response_match_score" |
| 402 | + assert df.iloc[0]["eval_status"] == "PASSED" |
| 403 | + assert df.iloc[0]["score"] == 1.0 |
| 404 | + |
| 405 | + |
| 406 | +def test_write_results_to_csv_appends_without_duplicate_header(tmp_path): |
| 407 | + output_file = os.path.join(str(tmp_path), "eval_results.csv") |
| 408 | + |
| 409 | + def _row(eval_id: str, score: float, status: str) -> dict: |
| 410 | + return { |
| 411 | + "eval_set_id": "s", |
| 412 | + "eval_id": eval_id, |
| 413 | + "metric_name": "response_match_score", |
| 414 | + "threshold": 0.8, |
| 415 | + "score": score, |
| 416 | + "eval_status": status, |
| 417 | + "prompt": "p", |
| 418 | + "expected_response": "e", |
| 419 | + "actual_response": "a", |
| 420 | + "expected_tool_calls": "", |
| 421 | + "actual_tool_calls": "", |
| 422 | + } |
| 423 | + |
| 424 | + AgentEvaluator._write_results_to_csv( |
| 425 | + rows=[_row("case_1", 1.0, "PASSED")], output_file=output_file |
| 426 | + ) |
| 427 | + AgentEvaluator._write_results_to_csv( |
| 428 | + rows=[_row("case_2", 0.0, "FAILED")], output_file=output_file |
| 429 | + ) |
| 430 | + |
| 431 | + df = pd.read_csv(output_file) |
| 432 | + # Two appends should accumulate two rows, with the header written only once. |
| 433 | + assert len(df) == 2 |
| 434 | + assert sorted(df["eval_id"].tolist()) == ["case_1", "case_2"] |
| 435 | + assert "eval_id" not in df["eval_id"].tolist() |
| 436 | + |
| 437 | + |
| 438 | +if __name__ == "__main__": |
| 439 | + raise SystemExit(pytest.main([__file__, "-v"])) |
0 commit comments