Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/google/adk/evaluation/agent_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from .eval_sets_manager import EvalSetsManager
from .evaluator import EvalStatus
from .in_memory_eval_sets_manager import InMemoryEvalSetsManager
from .llm_as_judge_utils import get_text_from_content
from .local_eval_sets_manager import convert_eval_set_to_pydantic_schema
from .simulation.user_simulator_provider import UserSimulatorProvider

Expand Down Expand Up @@ -601,10 +602,7 @@ def _print_details(

@staticmethod
def _convert_content_to_text(content: Optional[genai_types.Content]) -> str:
if content and content.parts:
return "\n".join([p.text for p in content.parts if p.text])

return ""
return get_text_from_content(content) or ""

@staticmethod
def _convert_tool_calls_to_text(
Expand Down
6 changes: 2 additions & 4 deletions src/google/adk/evaluation/final_response_match_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .evaluator import EvaluationResult
from .evaluator import Evaluator
from .evaluator import PerInvocationResult
from .llm_as_judge_utils import get_text_from_content


class RougeEvaluator(Evaluator):
Expand Down Expand Up @@ -90,10 +91,7 @@ def evaluate_invocations(


def _get_text_from_content(content: Optional[genai_types.Content]) -> str:
if content and content.parts:
return "\n".join([part.text for part in content.parts if part.text])

return ""
return get_text_from_content(content) or ""


def _get_eval_status(score: float, threshold: float) -> EvalStatus:
Expand Down
9 changes: 3 additions & 6 deletions src/google/adk/evaluation/hallucinations_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from .evaluator import PerInvocationResult
from .llm_as_judge_utils import get_eval_status
from .llm_as_judge_utils import get_text_from_content
from .llm_as_judge_utils import get_text_parts
from .llm_as_judge_utils import get_tool_declarations_as_json_str

logger = logging.getLogger("google_adk." + __name__)
Expand Down Expand Up @@ -461,7 +462,7 @@ def _create_context_for_step(
for part in event.content.parts
if part.function_response
]
nl_responses = [part.text for part in event.content.parts if part.text]
nl_responses = get_text_parts(event.content)

if nl_responses:
context_parts.append("\n".join(nl_responses) + "\n")
Expand Down Expand Up @@ -650,11 +651,7 @@ def _get_steps_to_evaluate(self, actual: Invocation) -> list[EvaluationStep]:

if self._criterion.evaluate_intermediate_nl_responses:
for event in all_events:
nl_parts = (
[p.text for p in event.content.parts if p.text]
if event.content and event.content.parts
else []
)
nl_parts = get_text_parts(event.content)
if nl_parts:
context = self._create_context_for_step(
actual.app_details, actual, events_for_context
Expand Down
9 changes: 8 additions & 1 deletion src/google/adk/evaluation/llm_as_judge_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ class Label(enum.Enum):
NOT_FOUND = "label field not found"


def get_text_parts(content: Optional[genai_types.Content]) -> list[str]:
"""Returns the visible text parts of a `Content`, excluding thoughts."""
if not content or not content.parts:
return []
return [p.text for p in content.parts if p.text and not p.thought]


def get_text_from_content(
content: Optional[Union[genai_types.Content, Invocation]],
*,
Expand Down Expand Up @@ -87,7 +94,7 @@ def get_text_from_content(
return "\n".join(parts) if parts else None

if content and content.parts:
return "\n".join([p.text for p in content.parts if p.text])
return "\n".join(get_text_parts(content))

return None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from .evaluator import _validate_invocation_lengths
from .evaluator import EvaluationResult
from .evaluator import PerInvocationResult
from .llm_as_judge_utils import get_text_parts
from .rubric_based_evaluator import RubricBasedEvaluator

logger = logging.getLogger("google_adk." + __name__)
Expand Down Expand Up @@ -193,7 +194,7 @@ def _assemble_dialogue_history(
for turn_index, invocation in enumerate(actual_invocations):
# USER TURN
if invocation.user_content and invocation.user_content.parts:
text_parts = [p.text for p in invocation.user_content.parts if p.text]
text_parts = get_text_parts(invocation.user_content)
if text_parts:
dialogue_lines.append(
f"USER TURN {turn_index + 1}: {' '.join(text_parts)}"
Expand All @@ -208,7 +209,7 @@ def _assemble_dialogue_history(
else f"AGENT ({event.author})"
)
if event.content and event.content.parts:
text_parts = [p.text for p in event.content.parts if p.text]
text_parts = get_text_parts(event.content)
if text_parts:
dialogue_lines.append(
f"{role} TURN {turn_index + 1}: {' '.join(text_parts)}"
Expand Down Expand Up @@ -245,7 +246,7 @@ def _assemble_dialogue_history(
):
agent_name = intermediate_data.invocation_events[0].author
role = f"AGENT ({agent_name})"
text_parts = [p.text for p in invocation.final_response.parts if p.text]
text_parts = get_text_parts(invocation.final_response)
if text_parts:
dialogue_lines.append(
f"{role} TURN {turn_index + 1}: {' '.join(text_parts)}"
Expand Down
6 changes: 2 additions & 4 deletions src/google/adk/evaluation/vertex_ai_eval_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from .evaluator import EvaluationResult
from .evaluator import Evaluator
from .evaluator import PerInvocationResult
from .llm_as_judge_utils import get_text_from_content

logger = logging.getLogger("google_adk." + __name__)

Expand Down Expand Up @@ -113,10 +114,7 @@ def evaluate_invocations(
"""

def _get_text(self, content: Optional[genai_types.Content]) -> str:
if content and content.parts:
return "\n".join([p.text for p in content.parts if p.text])

return ""
return get_text_from_content(content) or ""

def _get_score(self, eval_result: object) -> Optional[float]:
summary_metrics: object = getattr(eval_result, "summary_metrics", None)
Expand Down
11 changes: 11 additions & 0 deletions tests/unittests/evaluation/test_agent_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,17 @@ def _make_result_with_invocation(
)


def test_convert_content_to_text_excludes_thought_parts():
content = genai_types.Content(
parts=[
genai_types.Part(text="Consider the options.", thought=True),
genai_types.Part(text="Paris"),
]
)

assert AgentEvaluator._convert_content_to_text(content) == "Paris"


def test_get_results_as_rows_flattens_metrics_and_invocations():
eval_metric_results = {
"response_match_score": [
Expand Down
56 changes: 56 additions & 0 deletions tests/unittests/evaluation/test_final_response_match_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,62 @@ def _create_test_invocations(
)


@pytest.mark.parametrize(
"actual_parts, expected_parts, score",
[
(
[
genai_types.Part(
text="Consider the capital of France.", thought=True
),
genai_types.Part(text="Paris"),
],
[genai_types.Part(text="Paris")],
1.0,
),
(
[genai_types.Part(text="Paris", thought=False)],
[
genai_types.Part(
text="Consider the capital of France.", thought=True
),
genai_types.Part(text="Paris"),
],
1.0,
),
(
[genai_types.Part(text="Paris", thought=True)],
[genai_types.Part(text="Paris")],
0.0,
),
(
[
genai_types.Part(text="Paris", thought=True),
genai_types.Part(text="London"),
],
[genai_types.Part(text="Paris")],
0.0,
),
],
)
def test_response_match_scores_visible_text_only(
actual_parts, expected_parts, score
):
"""Thought summaries neither dilute correct answers nor credit wrong ones."""
actual, expected = _create_test_invocations("", "")
actual.final_response.parts = actual_parts
expected.final_response.parts = expected_parts
evaluator = _create_test_rouge_evaluator(threshold=0.5)

result = evaluator.evaluate_invocations([actual], [expected])

assert result.overall_score == pytest.approx(score)
assert result.per_invocation_results[0].score == pytest.approx(score)
assert result.overall_eval_status == (
EvalStatus.PASSED if score == 1.0 else EvalStatus.FAILED
)


def test_calculate_rouge_1_scores_empty_candidate_and_reference():
candidate = ""
reference = ""
Expand Down
48 changes: 48 additions & 0 deletions tests/unittests/evaluation/test_final_response_match_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,54 @@ def test_format_auto_rater_prompt_includes_intermediate_when_enabled():
assert "reference intro\nreference final" in prompt


def test_format_auto_rater_prompt_excludes_thought_parts():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
actual_invocation, expected_invocation = _create_test_invocations(
"candidate text", "reference text"
)
actual_invocation.final_response.parts.insert(
0, genai_types.Part(text="Considering the response.", thought=True)
)
expected_invocation.final_response.parts.insert(
0, genai_types.Part(text="Considering the reference.", thought=True)
)

prompt = evaluator.format_auto_rater_prompt(
actual_invocation, expected_invocation
)

assert "Considering the response." not in prompt
assert "Considering the reference." not in prompt
assert "candidate text" in prompt
assert "reference text" in prompt


def test_convert_auto_rater_response_to_score_ignores_judge_thought():
"""The judge model's own thought text must not break verdict parsing."""
evaluator = _create_test_evaluator_gemini(threshold=0.8)
auto_rater_response = """```json
{
"is_the_agent_response_valid": "valid",
"reasoning": "The response is valid."
}
```"""
llm_response = LlmResponse(
content=genai_types.Content(
parts=[
genai_types.Part(
text="Let me evaluate this response.", thought=True
),
genai_types.Part(text=auto_rater_response),
],
role="model",
)
)
auto_rater_score = evaluator.convert_auto_rater_response_to_score(
llm_response
)
assert auto_rater_score == AutoRaterScore(score=1.0)


def test_convert_auto_rater_response_to_score_valid():
evaluator = _create_test_evaluator_gemini(threshold=0.8)
auto_rater_response = """```json
Expand Down
Loading