Skip to content

Commit 4c0e70a

Browse files
committed
fix(eval): improve rubric text normalization for judge-garbled output
Replace _normalize_text's simple lower().strip() with NFKC unicode normalization, smart-quote/dash translation, and markdown artifact stripping. Add substring fallback with uniqueness guard to convert_auto_rater_response_to_score for cases where normalization alone isn't sufficient. Fixes #6072
1 parent 792775f commit 4c0e70a

2 files changed

Lines changed: 145 additions & 1 deletion

File tree

src/google/adk/evaluation/rubric_based_evaluator.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import abc
1818
import logging
1919
import re
20+
import unicodedata
2021
from typing import Optional
2122

2223
from typing_extensions import override
@@ -277,10 +278,30 @@ def summarize(
277278
)
278279

279280

281+
_SMART_CHARS = {
282+
0x2018: "'",
283+
0x2019: "'",
284+
0x201C: '"',
285+
0x201D: '"',
286+
0x2013: "-",
287+
0x2014: "-",
288+
0x2026: "...",
289+
}
290+
291+
280292
def _normalize_text(text: str) -> str:
281-
"""Returns a normalized version of the passed in text."""
293+
"""Returns a normalized version of the passed in text.
294+
295+
Handles common judge-model garbling: markdown bullets, smart quotes,
296+
bold/italic markers, en/em dashes, and extra whitespace.
297+
"""
282298
if not isinstance(text, str):
283299
return ""
300+
text = unicodedata.normalize("NFKC", text)
301+
text = text.translate(_SMART_CHARS)
302+
text = re.sub(r'^[\s*•\-"\']+', "", text)
303+
text = re.sub(r'[\s*•\-"\']+$', "", text)
304+
text = re.sub(r"\s+", " ", text)
284305
return text.lower().strip()
285306

286307

@@ -394,6 +415,16 @@ def convert_auto_rater_response_to_score(
394415
for rubric_response in rubric_responses:
395416
normalized_rubric_text = _normalize_text(rubric_response.property_text)
396417
rubric = normalized_rubric_to_rubric_map.get(normalized_rubric_text, None)
418+
419+
if not rubric:
420+
candidates = [
421+
r
422+
for ct, r in normalized_rubric_to_rubric_map.items()
423+
if ct in normalized_rubric_text or normalized_rubric_text in ct
424+
]
425+
if len(candidates) == 1:
426+
rubric = candidates[0]
427+
397428
if rubric:
398429
rubric_scores.append(
399430
RubricScore(

tests/unittests/evaluation/test_rubric_based_evaluator.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from google.adk.evaluation.rubric_based_evaluator import DefaultAutoRaterResponseParser
2929
from google.adk.evaluation.rubric_based_evaluator import MajorityVotePerInvocationResultsAggregator
3030
from google.adk.evaluation.rubric_based_evaluator import MeanInvocationResultsSummarizer
31+
from google.adk.evaluation.rubric_based_evaluator import _normalize_text
3132
from google.adk.evaluation.rubric_based_evaluator import RubricBasedEvaluator
3233
from google.adk.models.llm_response import LlmResponse
3334
from google.genai import types as genai_types
@@ -658,3 +659,115 @@ def test_create_effective_rubrics_filters_by_rubric_type(
658659
"2",
659660
"test_type_rubric",
660661
}
662+
663+
664+
class TestNormalizeText:
665+
"""Validate _normalize_text handles common judge-model garbling patterns."""
666+
667+
RUBRIC = "the response correctly uses tools"
668+
669+
@pytest.mark.parametrize(
670+
"label,input_text",
671+
[
672+
("exact", "The response correctly uses tools"),
673+
("markdown_bullet", "- The response correctly uses tools"),
674+
("bullet_bold", "* **The response correctly uses tools**"),
675+
(
676+
"smart_double_quotes",
677+
"“The response correctly uses tools”",
678+
),
679+
("double_spaces", "The response correctly uses tools"),
680+
(
681+
"em_dash_prefix",
682+
"— The response correctly uses tools",
683+
),
684+
(
685+
"en_dash_prefix",
686+
"– The response correctly uses tools",
687+
),
688+
(
689+
"unicode_bullet",
690+
"• The response correctly uses tools",
691+
),
692+
(
693+
"leading_whitespace",
694+
" The response correctly uses tools",
695+
),
696+
],
697+
)
698+
def test_garbled_text_matches_rubric(self, label, input_text):
699+
assert _normalize_text(input_text) == self.RUBRIC
700+
701+
def test_ellipsis_normalized(self):
702+
assert (
703+
_normalize_text("The response… uses tools")
704+
== "the response... uses tools"
705+
)
706+
707+
def test_accented_chars_preserved(self):
708+
assert _normalize_text("réponse") == "réponse"
709+
710+
def test_non_string_returns_empty(self):
711+
assert _normalize_text(None) == ""
712+
assert _normalize_text(42) == ""
713+
714+
def test_empty_string(self):
715+
assert _normalize_text("") == ""
716+
717+
718+
class TestSubstringFallbackUniquenessGuard:
719+
"""Verify substring fallback only matches when exactly one candidate exists.
720+
721+
The convert_auto_rater_response_to_score method falls back to substring
722+
matching when exact normalized match fails. When multiple rubrics share
723+
a common substring, the fallback must reject the ambiguous match.
724+
"""
725+
726+
def _build_evaluator_and_score(self, rubric_texts, judge_property_text):
727+
"""Helper: build a FakeRubricBasedEvaluator and score a judge response."""
728+
rubrics = []
729+
for i, text in enumerate(rubric_texts):
730+
rubrics.append(
731+
Rubric(
732+
rubric_id=f"rubric_{i}",
733+
rubric_content=RubricContent(text_property=text),
734+
)
735+
)
736+
737+
metric = EvalMetric(
738+
metric_name="test_metric",
739+
threshold=0.5,
740+
criterion=RubricsBasedCriterion(rubrics=rubrics, threshold=0.5),
741+
)
742+
evaluator = FakeRubricBasedEvaluator(eval_metric=metric)
743+
evaluator.create_effective_rubrics_list([])
744+
745+
response_text = (
746+
f"Property: {judge_property_text}\n"
747+
"Rationale: test rationale\n"
748+
"Verdict: yes"
749+
)
750+
response = LlmResponse(
751+
content=genai_types.Content(
752+
parts=[genai_types.Part(text=response_text)]
753+
)
754+
)
755+
return evaluator.convert_auto_rater_response_to_score(response)
756+
757+
def test_unique_substring_match_accepted(self):
758+
result = self._build_evaluator_and_score(
759+
rubric_texts=["Uses tools correctly"],
760+
judge_property_text="Uses tools correctly",
761+
)
762+
assert len(result.rubric_scores) == 1
763+
assert result.rubric_scores[0].rubric_id == "rubric_0"
764+
765+
def test_ambiguous_substring_match_rejected(self):
766+
result = self._build_evaluator_and_score(
767+
rubric_texts=[
768+
"Uses tools correctly",
769+
"Uses tools efficiently",
770+
],
771+
judge_property_text="Uses tools",
772+
)
773+
assert len(result.rubric_scores) == 0

0 commit comments

Comments
 (0)