ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
194 lines
6.6 KiB
Python
194 lines
6.6 KiB
Python
# 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.
|
|
|
|
"""Library for rating agent trajectories."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
from absl import logging
|
|
from google.genai import types
|
|
import jinja2
|
|
from retry import retry
|
|
|
|
from google import genai
|
|
|
|
|
|
def parse_rubric_validation_response(
|
|
rubric_val_response: str,
|
|
) -> dict[str, str]:
|
|
"""Parses rubric validation response text into a dictionary.
|
|
|
|
Args:
|
|
rubric_val_response: The text response from rubric validation.
|
|
|
|
Returns:
|
|
A dictionary containing parsed property, evidence, rationale, and verdict.
|
|
"""
|
|
PROPERTY_PATTERN = (
|
|
r'Property:\s*([\s\S]*?)(?=(?:Evidence:|Rationale:|Verdict:|$))'
|
|
)
|
|
EVIDENCE_PATTERN = r'Evidence:\s*([\s\S]*?)(?=(?:Rationale:|Verdict:|$))'
|
|
RATIONALE_PATTERN = r'Rationale:\s*([\s\S]*?)(?=(?:Evidence:|Verdict:|$))'
|
|
VERDICT_PATTERN = r'Verdict:\s*([\s\S]*?)(?=(?:Evidence:|Rationale:|$))'
|
|
|
|
property_list = []
|
|
evidence_list = []
|
|
rationale_list = []
|
|
fulfillment_list = []
|
|
property_blocks = rubric_val_response.split('Property: ')[1:]
|
|
for property_block in property_blocks:
|
|
property_name = re.search(PROPERTY_PATTERN, 'Property: ' + property_block)
|
|
if property_name is None:
|
|
continue
|
|
property_name = property_name.group(1).strip()
|
|
property_list.append(property_name)
|
|
|
|
evidence_match = re.search(EVIDENCE_PATTERN, property_block, re.DOTALL)
|
|
evidence = evidence_match.group(1).strip() if evidence_match else ''
|
|
evidence_list.append(evidence)
|
|
|
|
rationale_match = re.search(RATIONALE_PATTERN, property_block, re.DOTALL)
|
|
rationale = rationale_match.group(1).strip() if rationale_match else ''
|
|
rationale_list.append(rationale)
|
|
|
|
verdict = re.search(VERDICT_PATTERN, property_block)
|
|
if verdict is None:
|
|
verdict_str = 'not_found'
|
|
else:
|
|
verdict_str = verdict.group(1).strip().lower()
|
|
if 'yes' in verdict_str:
|
|
verdict_str = 'yes'
|
|
elif 'no' in verdict_str:
|
|
verdict_str = 'no'
|
|
elif 'unknown' in verdict_str:
|
|
verdict_str = 'unknown'
|
|
else:
|
|
verdict_str = 'not_found'
|
|
fulfillment_list.append(verdict_str)
|
|
return dict(
|
|
property=property_list[0],
|
|
evidence=evidence_list[0],
|
|
rationale=rationale_list[0],
|
|
verdict=fulfillment_list[0],
|
|
)
|
|
|
|
|
|
def format_user_agent_conversation(conv: list[dict[str, Any]]) -> str:
|
|
"""Formats a conversation between user and agent into a string.
|
|
|
|
Args:
|
|
conv: A list of conversation turns.
|
|
|
|
Returns:
|
|
A formatted string representing the conversation.
|
|
"""
|
|
# conv is a list in this eval data
|
|
# if not, manually convert to list to re-use these logics
|
|
# if not isinstance(conv, list):
|
|
# conv = [conv]
|
|
res = ''
|
|
turn_idx = 1
|
|
for turn in conv:
|
|
# if 'request' in conv[turn]:\
|
|
role = turn['role']
|
|
for part in turn['parts']:
|
|
if role == 'user' and (txt := part.get('text')):
|
|
res = res + f'USER TURN {turn_idx}:\n' + txt + '\n'
|
|
turn_idx += 1
|
|
elif role == 'model' and (txt := part.get('text')):
|
|
res = res + f'The agent response is: {txt}' + '\n'
|
|
elif fc := part.get('function_call'):
|
|
res = (
|
|
res
|
|
+ f'The agent called the function {fc["name"]} with the following'
|
|
f' function arguments: {fc["args"]}.\n'
|
|
)
|
|
elif fc := part.get('function_response'):
|
|
res = (
|
|
res
|
|
+ 'The execution result from the agent of function'
|
|
f' {fc["name"]} is: \n{fc["response"]}\n'
|
|
)
|
|
return res
|
|
|
|
|
|
_COMPLETION_RUBRIC_CRITERIA = """The agent fulfilled the user's primary request. Description: It measures if the agent successfully completed the action the user initiated the contact for (e.g., processed a return, provided a tracking number, answered a policy question). A "yes" requires confirmed completion within the transcript."""
|
|
|
|
|
|
class Rater:
|
|
"""Rates agent trajectories using an LLM based on rubrics."""
|
|
|
|
def __init__(
|
|
self,
|
|
tool_declarations: str,
|
|
developer_instructions: str = '',
|
|
rubric: str = _COMPLETION_RUBRIC_CRITERIA,
|
|
validation_template_path: str = 'rubric_validation_template.txt',
|
|
):
|
|
"""Initializes the Rater.
|
|
|
|
Args:
|
|
tool_declarations: JSON string of tool declarations for the agent.
|
|
developer_instructions: Developer instructions.
|
|
rubric: rubric.
|
|
validation_template_path: Path to rubric validation template.
|
|
"""
|
|
self._client = genai.Client()
|
|
self._tool_declarations = tool_declarations
|
|
self._developer_instructions = developer_instructions
|
|
with open(validation_template_path) as f:
|
|
self._rubric_validation_template = f.read().strip()
|
|
logging.info(
|
|
'Loaded rubric validate template from path=%s', validation_template_path
|
|
)
|
|
self._rubric = rubric
|
|
|
|
@retry(tries=3, delay=2, backoff=2)
|
|
def __call__(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""Rates a conversation based on rubric criteria.
|
|
|
|
Args:
|
|
messages: A list of conversation messages between user and agent.
|
|
|
|
Returns:
|
|
A dictionary containing rating information including score.
|
|
"""
|
|
env = jinja2.Environment(autoescape=True)
|
|
env.globals['user_input'] = (
|
|
messages[0].get('parts', [{}])[0].get('text', '') if messages else ''
|
|
)
|
|
env.globals['developer_instructions'] = self._developer_instructions
|
|
env.globals['tool_declarations'] = self._tool_declarations
|
|
env.globals['model_response'] = format_user_agent_conversation(messages)
|
|
env.globals['decomposed_rubric'] = '* ' + self._rubric
|
|
contents = env.from_string(self._rubric_validation_template).render()
|
|
resp = self._client.models.generate_content(
|
|
model='gemini-2.5-pro',
|
|
contents=contents,
|
|
config=types.GenerateContentConfig(
|
|
candidate_count=1,
|
|
thinking_config=types.ThinkingConfig(
|
|
include_thoughts=True, thinking_budget=-1
|
|
),
|
|
),
|
|
)
|
|
got = parse_rubric_validation_response(resp.text)
|
|
got = dict(got)
|
|
got['score'] = float(got['verdict'] == 'yes')
|
|
got['rating_criteria'] = got.pop('property')
|
|
return got
|