# 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 logging from google.adk.evaluation.eval_case import Invocation from google.adk.evaluation.eval_metrics import EvalMetric from google.adk.evaluation.eval_metrics import JudgeModelOptions from google.adk.evaluation.eval_metrics import PrebuiltMetrics from google.adk.evaluation.eval_metrics import RubricsBasedCriterion from google.adk.evaluation.eval_rubrics import Rubric from google.adk.evaluation.eval_rubrics import RubricContent from google.adk.evaluation.eval_rubrics import RubricScore from google.adk.evaluation.evaluator import EvalStatus from google.adk.evaluation.evaluator import PerInvocationResult from google.adk.evaluation.llm_as_judge_utils import get_average_rubric_score from google.adk.evaluation.rubric_based_evaluator import DefaultAutoRaterResponseParser from google.adk.evaluation.rubric_based_evaluator import MajorityVotePerInvocationResultsAggregator from google.adk.evaluation.rubric_based_evaluator import MeanInvocationResultsSummarizer from google.adk.evaluation.rubric_based_evaluator import RubricBasedEvaluator from google.adk.models.llm_response import LlmResponse from google.genai import types as genai_types import pytest class FakeRubricBasedEvaluator(RubricBasedEvaluator): """A fake implementation of RubricBasedEvaluator intended for testing.""" def __init__( self, eval_metric: EvalMetric, rubric_type: str | None = None, ): super().__init__( eval_metric, criterion_type=RubricsBasedCriterion, rubric_type=rubric_type, ) def format_auto_rater_prompt( self, actual: Invocation, expected: Invocation ) -> str: return "fake response" def _create_per_invocation_result( rubric_scores: list[RubricScore], ) -> PerInvocationResult: """Helper to create a PerInvocationResult.""" return PerInvocationResult( actual_invocation=Invocation( user_content=genai_types.Content( parts=[genai_types.Part(text="part_1")] ) ), expected_invocation=Invocation( user_content=genai_types.Content( parts=[genai_types.Part(text="part_2")] ) ), score=get_average_rubric_score(rubric_scores), rubric_scores=rubric_scores, eval_status=EvalStatus.NOT_EVALUATED, ) class TestDefaultAutoRaterResponseParser: """Test cases for DefaultAutoRaterResponseParser.""" def test_parse_auto_rater_response_with_empty_string(self): """Tests _parse_auto_rater_response with an empty string.""" assert DefaultAutoRaterResponseParser().parse("") == [] def test_parse_auto_rater_response_with_malformed_string(self): """Tests _parse_auto_rater_response with a malformed string.""" response = "This is just some random text without the expected format." assert DefaultAutoRaterResponseParser().parse(response) == [] def test_parse_auto_rater_response_with_single_yes_verdict(self): """Tests _parse_auto_rater_response with a single 'yes' verdict.""" response = """ Property: Is the response good? Rationale: It was good. Verdict: yes """ parsed = DefaultAutoRaterResponseParser().parse(response) assert len(parsed) == 1 assert parsed[0].property_text == "Is the response good?" assert parsed[0].rationale == "It was good." assert parsed[0].score == 1.0 def test_parse_auto_rater_response_with_single_no_verdict(self): """Tests _parse_auto_rater_response with a single 'no' verdict.""" response = """ Property: Is the response bad? Rationale: It was bad. Verdict: no """ parsed = DefaultAutoRaterResponseParser().parse(response) assert len(parsed) == 1 assert parsed[0].property_text == "Is the response bad?" assert parsed[0].rationale == "It was bad." assert parsed[0].score == 0.0 def test_parse_auto_rater_response_with_invalid_verdict(self): """Tests _parse_auto_rater_response with an invalid verdict.""" response = """ Property: Is it unclear? Rationale: I cannot tell. Verdict: maybe """ parsed = DefaultAutoRaterResponseParser().parse(response) assert len(parsed) == 1 assert parsed[0].property_text == "Is it unclear?" assert parsed[0].rationale == "I cannot tell." assert parsed[0].score is None def test_parse_auto_rater_response_with_multiple_verdicts(self): """Tests _parse_auto_rater_response with multiple verdicts.""" response = """ Property: Is the response good? Rationale: It was good. Verdict: yes Property: Is the response bad? Rationale: It was not bad. Verdict: no """ parsed = DefaultAutoRaterResponseParser().parse(response) assert len(parsed) == 2 assert parsed[0].property_text == "Is the response good?" assert parsed[0].rationale == "It was good." assert parsed[0].score == 1.0 assert parsed[1].property_text == "Is the response bad?" assert parsed[1].rationale == "It was not bad." assert parsed[1].score == 0.0 def test_parse_auto_rater_response_with_incomplete_entry(self): """Tests _parse_auto_rater_response with an incomplete entry.""" response = """ Property: Is the response good? Rationale: It was good. Verdict: yes Property: Is the response bad? Rationale: It was not bad. """ # Missing Verdict parsed = DefaultAutoRaterResponseParser().parse(response) assert len(parsed) == 1 # zip will only create one item assert parsed[0].property_text == "Is the response good?" def test_parse_auto_rater_response_with_case_insensitive_verdict(self): """Tests _parse_auto_rater_response is case-insensitive for verdicts.""" response = """ Property: Is the response good? Rationale: It was good. Verdict: Yes Property: Is the response bad? Rationale: It was bad. Verdict: NO """ parsed = DefaultAutoRaterResponseParser().parse(response) assert len(parsed) == 2 assert parsed[0].score == 1.0 assert parsed[1].score == 0.0 class TestMajorityVotePerInvocationResultsAggregator: def test_aggregate_per_invocation_samples_with_no_rubric_scores( self, ): """Tests aggregation when samples have no rubric scores.""" samples = [ _create_per_invocation_result([]), _create_per_invocation_result([]), ] result = MajorityVotePerInvocationResultsAggregator().aggregate( samples, threshold=0.5 ) assert result.score is None assert result.rubric_scores == [] def test_aggregate_per_invocation_samples_with_majority_positive( self, ): """Tests aggregation with a majority of positive scores.""" samples = [ _create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]), _create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]), _create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]), ] result = MajorityVotePerInvocationResultsAggregator().aggregate( samples, threshold=0.5 ) assert result.score == 1.0 assert len(result.rubric_scores) == 1 assert result.rubric_scores[0].rubric_id == "1" assert result.rubric_scores[0].score == 1.0 def test_aggregate_per_invocation_samples_with_majority_negative( self, ): """Tests aggregation with a majority of negative scores.""" samples = [ _create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]), _create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]), _create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]), ] result = MajorityVotePerInvocationResultsAggregator().aggregate( samples, threshold=0.5 ) assert result.score == 0.0 assert len(result.rubric_scores) == 1 assert result.rubric_scores[0].rubric_id == "1" assert result.rubric_scores[0].score == 0.0 def test_aggregate_per_invocation_samples_with_tie_verdicts( self, ): """Tests aggregation with a tie, where negative should win.""" samples = [ _create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]), _create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]), ] result = MajorityVotePerInvocationResultsAggregator().aggregate( samples, threshold=0.5 ) assert result.score == 0.0 assert len(result.rubric_scores) == 1 assert result.rubric_scores[0].rubric_id == "1" assert result.rubric_scores[0].score == 0.0 def test_aggregate_per_invocation_samples_with_all_none_scores( self, ): """Tests aggregation when all samples have a score of None.""" samples = [ _create_per_invocation_result( [RubricScore(rubric_id="1", score=None, rationale="r1")] ), _create_per_invocation_result( [RubricScore(rubric_id="1", score=None, rationale="r2")] ), ] result = MajorityVotePerInvocationResultsAggregator().aggregate( samples, threshold=0.5 ) assert result.score is None assert len(result.rubric_scores) == 1 assert result.rubric_scores[0].rubric_id == "1" assert result.rubric_scores[0].score is None assert result.rubric_scores[0].rationale == "r1" def test_aggregate_per_invocation_samples_with_multiple_rubrics( self, ): """Tests aggregation with multiple rubrics.""" samples = [ _create_per_invocation_result([ RubricScore(rubric_id="1", score=1.0), RubricScore(rubric_id="2", score=0.0), ]), _create_per_invocation_result([ RubricScore(rubric_id="1", score=1.0), RubricScore(rubric_id="2", score=0.0), ]), _create_per_invocation_result([ RubricScore(rubric_id="1", score=0.0), RubricScore(rubric_id="2", score=1.0), ]), ] result = MajorityVotePerInvocationResultsAggregator().aggregate( samples, threshold=0.5 ) assert result.score == 0.5 assert len(result.rubric_scores) == 2 rubric1_score = next( (s for s in result.rubric_scores if s.rubric_id == "1"), None ) rubric2_score = next( (s for s in result.rubric_scores if s.rubric_id == "2"), None ) assert rubric1_score is not None assert rubric1_score.score == 1.0 assert rubric2_score is not None assert rubric2_score.score == 0.0 class TestMeanInvocationResultsSummarizer: """Test cases for MeanInvocationResultsSummarizer.""" def test_summarize_with_empty_list( self, ): """Tests aggregate_invocation_results with an empty list.""" result = MeanInvocationResultsSummarizer().summarize([], threshold=0.5) assert result.overall_score is None assert result.overall_rubric_scores == [] assert result.per_invocation_results == [] def test_summarize_with_no_rubric_scores( self, ): """Tests aggregate_invocation_results with samples that have no rubric scores.""" invocations = [ _create_per_invocation_result([]), _create_per_invocation_result([]), ] result = MeanInvocationResultsSummarizer().summarize( invocations, threshold=0.5 ) assert result.overall_score is None assert result.overall_rubric_scores == [] assert result.per_invocation_results == invocations def test_summarize_with_single_invocation( self, ): """Tests aggregate_invocation_results with a single invocation result.""" invocations = [ _create_per_invocation_result([ RubricScore(rubric_id="1", score=1.0), RubricScore(rubric_id="2", score=0.0), ]) ] result = MeanInvocationResultsSummarizer().summarize( invocations, threshold=0.5 ) assert result.overall_score == 0.5 assert len(result.overall_rubric_scores) == 2 rubric1_score = next( s for s in result.overall_rubric_scores if s.rubric_id == "1" ) rubric2_score = next( s for s in result.overall_rubric_scores if s.rubric_id == "2" ) assert rubric1_score.score == 1.0 assert rubric2_score.score == 0.0 def test_summarize_with_multiple_invocations_single_rubric( self, ): """Tests aggregate_invocation_results with multiple invocations for a single rubric.""" invocations = [ _create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]), _create_per_invocation_result([RubricScore(rubric_id="1", score=0.0)]), _create_per_invocation_result([RubricScore(rubric_id="1", score=1.0)]), ] result = MeanInvocationResultsSummarizer().summarize( invocations, threshold=0.5 ) assert result.overall_score == pytest.approx(2 / 3) assert len(result.overall_rubric_scores) == 1 assert result.overall_rubric_scores[0].rubric_id == "1" assert result.overall_rubric_scores[0].score == pytest.approx(2 / 3) def test_summarize_with_multiple_invocations_and_rubrics( self, ): """Tests aggregate_invocation_results with multiple invocations and rubrics.""" invocations = [ _create_per_invocation_result([ RubricScore(rubric_id="1", score=1.0), RubricScore(rubric_id="2", score=0.0), ]), _create_per_invocation_result([ RubricScore(rubric_id="1", score=0.0), RubricScore(rubric_id="2", score=1.0), ]), ] result = MeanInvocationResultsSummarizer().summarize( invocations, threshold=0.5 ) assert result.overall_score == 0.5 assert len(result.overall_rubric_scores) == 2 rubric1_score = next( s for s in result.overall_rubric_scores if s.rubric_id == "1" ) rubric2_score = next( s for s in result.overall_rubric_scores if s.rubric_id == "2" ) assert rubric1_score.score == 0.5 assert rubric2_score.score == 0.5 def test_summarize_with_none_scores( self, ): """Tests aggregate_invocation_results with some None scores.""" invocations = [ _create_per_invocation_result([ RubricScore(rubric_id="1", score=1.0), RubricScore(rubric_id="2", score=None), ]), _create_per_invocation_result([ RubricScore(rubric_id="1", score=0.0), RubricScore(rubric_id="2", score=1.0), ]), ] result = MeanInvocationResultsSummarizer().summarize( invocations, threshold=0.5 ) assert result.overall_score == pytest.approx(2 / 3) assert len(result.overall_rubric_scores) == 2 rubric1_score = next( s for s in result.overall_rubric_scores if s.rubric_id == "1" ) rubric2_score = next( s for s in result.overall_rubric_scores if s.rubric_id == "2" ) assert rubric1_score.score == 0.5 assert rubric2_score.score == 1.0 class TestRubricBasedEvaluator: """Tests for RubricBasedEvaluator.""" @pytest.fixture def evaluator(self) -> FakeRubricBasedEvaluator: """Returns a RubricBasedFinalResponseQualityV1Evaluator.""" rubrics = [ Rubric( rubric_id="1", rubric_content=RubricContent(text_property="Is the response good?"), ), Rubric( rubric_id="2", rubric_content=RubricContent(text_property="Is the response bad?"), ), ] judge_model_options = JudgeModelOptions( judge_model_config=None, num_samples=3, ) criterion = RubricsBasedCriterion( threshold=0.5, rubrics=rubrics, judge_model_options=judge_model_options ) metric = EvalMetric( metric_name=PrebuiltMetrics.RUBRIC_BASED_FINAL_RESPONSE_QUALITY_V1.value, threshold=0.5, criterion=criterion, ) return FakeRubricBasedEvaluator(metric) def test_convert_auto_rater_response_to_score_with_empty_response( self, evaluator: RubricBasedEvaluator, ): """Tests convert_auto_rater_response_to_score with an empty response.""" evaluator.create_effective_rubrics_list(None) response = LlmResponse( content=genai_types.Content(parts=[genai_types.Part(text="")]) ) auto_rater_score = evaluator.convert_auto_rater_response_to_score(response) assert auto_rater_score.score is None assert auto_rater_score.rubric_scores == [] def test_convert_auto_rater_response_to_score_with_malformed_response( self, evaluator: RubricBasedEvaluator, ): """Tests convert_auto_rater_response_to_score with a malformed response.""" evaluator.create_effective_rubrics_list(None) response = LlmResponse( content=genai_types.Content( parts=[genai_types.Part(text="This is not a valid format.")] ) ) auto_rater_score = evaluator.convert_auto_rater_response_to_score(response) assert auto_rater_score.score is None assert auto_rater_score.rubric_scores == [] def test_convert_auto_rater_response_to_score_with_none_content( self, evaluator: RubricBasedEvaluator, caplog: pytest.LogCaptureFixture, ): """An empty auto-rater response is scored as empty, not crashed on.""" evaluator.create_effective_rubrics_list(None) response = LlmResponse(content=None) with caplog.at_level(logging.WARNING): auto_rater_score = evaluator.convert_auto_rater_response_to_score( response ) assert auto_rater_score.score is None assert auto_rater_score.rubric_scores == [] assert "empty response" in caplog.text def test_convert_auto_rater_response_to_score_warns_on_unparseable( self, evaluator: RubricBasedEvaluator, caplog: pytest.LogCaptureFixture, ): """Auto-rater output that misses the expected format logs a diagnostic.""" evaluator.create_effective_rubrics_list(None) response = LlmResponse( content=genai_types.Content( parts=[genai_types.Part(text="**Verdict**: Yes")] ) ) with caplog.at_level(logging.WARNING): auto_rater_score = evaluator.convert_auto_rater_response_to_score( response ) assert auto_rater_score.rubric_scores == [] assert "did not match the expected" in caplog.text def test_convert_auto_rater_response_to_score_with_mixed_verdicts( self, evaluator: RubricBasedEvaluator, ): """Tests convert_auto_rater_response_to_score with mixed verdicts.""" evaluator.create_effective_rubrics_list(None) response_text = """ Property: Is the response good? Rationale: It was good. Verdict: yes Property: Is the response bad? Rationale: It was bad. Verdict: no """ response = LlmResponse( content=genai_types.Content( parts=[genai_types.Part(text=response_text)] ) ) auto_rater_score = evaluator.convert_auto_rater_response_to_score(response) assert auto_rater_score.score == 0.5 assert len(auto_rater_score.rubric_scores) == 2 assert auto_rater_score.rubric_scores[0].score == 1.0 assert auto_rater_score.rubric_scores[1].score == 0.0 def test_convert_auto_rater_response_to_score_with_invalid_verdict( self, evaluator: RubricBasedEvaluator, ): """Tests convert_auto_rater_response_to_score with an invalid verdict.""" evaluator.create_effective_rubrics_list(None) response_text = """ Property: Is the response good? Rationale: It was good. Verdict: yes Property: Is the response bad? Rationale: I cannot tell. Verdict: invalid """ response = LlmResponse( content=genai_types.Content( parts=[genai_types.Part(text=response_text)] ) ) auto_rater_score = evaluator.convert_auto_rater_response_to_score(response) assert auto_rater_score.score == 1.0 assert len(auto_rater_score.rubric_scores) == 2 assert auto_rater_score.rubric_scores[0].score == 1.0 assert auto_rater_score.rubric_scores[1].score is None def test_convert_auto_rater_response_to_score_with_unknown_property( self, evaluator: RubricBasedEvaluator, ): """Tests convert_auto_rater_response_to_score with an unknown property.""" evaluator.create_effective_rubrics_list(None) response_text = """ Property: Is the response amazing? Rationale: It was amazing. Verdict: yes """ response = LlmResponse( content=genai_types.Content( parts=[genai_types.Part(text=response_text)] ) ) auto_rater_score = evaluator.convert_auto_rater_response_to_score(response) assert auto_rater_score.score is None assert auto_rater_score.rubric_scores == [] def test_create_effective_rubrics_list_with_invocation_rubrics( self, evaluator: RubricBasedEvaluator ): invocation_rubrics = [ Rubric( rubric_id="3", rubric_content=RubricContent(text_property="Invocation rubric"), ) ] evaluator.create_effective_rubrics_list(invocation_rubrics) effective_rubrics = evaluator.get_effective_rubrics_list() assert len(effective_rubrics) == 3 assert {r.rubric_id for r in effective_rubrics} == {"1", "2", "3"} def test_create_effective_rubrics_list_with_duplicate_invocation_rubric_id( self, evaluator: RubricBasedEvaluator ): invocation_rubrics = [ Rubric( rubric_id="1", rubric_content=RubricContent(text_property="Invocation rubric"), ) ] with pytest.raises( ValueError, match="Rubric with rubric_id '1' already exists." ): evaluator.create_effective_rubrics_list(invocation_rubrics) def test_create_effective_rubrics_list_with_no_invocation_rubrics( self, evaluator: RubricBasedEvaluator ): evaluator.create_effective_rubrics_list(None) effective_rubrics = evaluator.get_effective_rubrics_list() assert len(effective_rubrics) == 2 assert {r.rubric_id for r in effective_rubrics} == {"1", "2"} def test_get_effective_rubrics_list_before_creation_raises_error( self, evaluator: RubricBasedEvaluator ): with pytest.raises( ValueError, match="Effective rubrics list not initialized." ): evaluator.get_effective_rubrics_list() def test_create_effective_rubrics_list_multiple_calls( self, evaluator: RubricBasedEvaluator ): invocation_rubrics1 = [ Rubric( rubric_id="3", rubric_content=RubricContent(text_property="Invocation rubric 1"), ) ] evaluator.create_effective_rubrics_list(invocation_rubrics1) effective_rubrics1 = evaluator.get_effective_rubrics_list() assert len(effective_rubrics1) == 3 assert {r.rubric_id for r in effective_rubrics1} == {"1", "2", "3"} invocation_rubrics2 = [ Rubric( rubric_id="4", rubric_content=RubricContent(text_property="Invocation rubric 2"), ) ] evaluator.create_effective_rubrics_list(invocation_rubrics2) effective_rubrics2 = evaluator.get_effective_rubrics_list() assert len(effective_rubrics2) == 3 assert {r.rubric_id for r in effective_rubrics2} == {"1", "2", "4"} def test_create_effective_rubrics_filters_by_rubric_type( self, evaluator: RubricBasedEvaluator ): evaluator_with_type = FakeRubricBasedEvaluator( evaluator._eval_metric, rubric_type="TEST_TYPE" ) invocation_rubrics = [ Rubric( rubric_id="test_type_rubric", rubric_content=RubricContent(text_property="Invocation rubric 1"), type="TEST_TYPE", ), Rubric( rubric_id="other_type_rubric", rubric_content=RubricContent(text_property="Invocation rubric 2"), type="OTHER_TYPE", ), ] evaluator_with_type.create_effective_rubrics_list(invocation_rubrics) effective_rubrics = evaluator_with_type.get_effective_rubrics_list() assert len(effective_rubrics) == 3 assert {r.rubric_id for r in effective_rubrics} == { "1", "2", "test_type_rubric", }