chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,420 @@
|
||||
# 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
|
||||
|
||||
from google.adk.evaluation import conversation_scenarios
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulator
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
|
||||
from google.adk.evaluation.simulation.user_simulator import Status
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
|
||||
from google.adk.events.event import Event
|
||||
from google.genai import types
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
_INPUT_EVENTS = [
|
||||
Event(
|
||||
author="user",
|
||||
content=types.Content(
|
||||
parts=[types.Part(text="Can you help me?")], role="user"
|
||||
),
|
||||
invocation_id="inv1",
|
||||
),
|
||||
Event(
|
||||
author="helpful_assistant",
|
||||
content=types.Content(
|
||||
parts=[
|
||||
types.Part(
|
||||
text="I'll get the user's name and greet them first.",
|
||||
thought=True,
|
||||
),
|
||||
types.Part(
|
||||
function_call=types.FunctionCall(name="get_user_name")
|
||||
),
|
||||
types.Part(
|
||||
function_response=types.FunctionResponse(
|
||||
name="get_user_name",
|
||||
response={"name": "John Doe"},
|
||||
)
|
||||
),
|
||||
types.Part(text="Hi John, what can I do for you?"),
|
||||
],
|
||||
role="model",
|
||||
),
|
||||
invocation_id="inv1",
|
||||
),
|
||||
]
|
||||
|
||||
_INPUT_EVENTS_LONG = _INPUT_EVENTS + [
|
||||
Event(
|
||||
author="user",
|
||||
content=types.Content(
|
||||
parts=[types.Part(text="I need to book a flight.")], role="user"
|
||||
),
|
||||
invocation_id="inv2",
|
||||
),
|
||||
Event(
|
||||
author="helpful_assistant",
|
||||
content=types.Content(
|
||||
parts=[
|
||||
types.Part(
|
||||
text="Sure, what is your departure date and destination?",
|
||||
),
|
||||
],
|
||||
role="model",
|
||||
),
|
||||
invocation_id="inv2",
|
||||
),
|
||||
]
|
||||
|
||||
_EXPECTED_REWRITTEN_DIALOGUE = """user: Can you help me?
|
||||
|
||||
helpful_assistant: Hi John, what can I do for you?"""
|
||||
|
||||
_EXPECTED_REWRITTEN_DIALOGUE_LONG = _EXPECTED_REWRITTEN_DIALOGUE + """
|
||||
|
||||
user: I need to book a flight.
|
||||
|
||||
helpful_assistant: Sure, what is your departure date and destination?"""
|
||||
|
||||
|
||||
def test_llm_backed_user_simulator_config_validation():
|
||||
"""Tests for LlmBackedUserSimulatorConfig."""
|
||||
config = LlmBackedUserSimulatorConfig(custom_instructions=None)
|
||||
assert config.custom_instructions is None
|
||||
valid_instructions = (
|
||||
"{{ stop_signal }} {{ conversation_plan }} {{ conversation_history }}"
|
||||
)
|
||||
config = LlmBackedUserSimulatorConfig(custom_instructions=valid_instructions)
|
||||
assert config.custom_instructions == valid_instructions
|
||||
invalid_instructions = "Instructions with missing formatting placeholders"
|
||||
with pytest.raises(ValidationError):
|
||||
LlmBackedUserSimulatorConfig(custom_instructions=invalid_instructions)
|
||||
|
||||
|
||||
class TestHelperMethods:
|
||||
"""Test cases for LlmBackedUserSimulator helper methods."""
|
||||
|
||||
def test_convert_conversation_to_user_sim_pov(self):
|
||||
"""Tests _convert_conversation_to_user_sim_pov method."""
|
||||
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
|
||||
_INPUT_EVENTS
|
||||
)
|
||||
assert rewritten_dialogue == _EXPECTED_REWRITTEN_DIALOGUE
|
||||
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
|
||||
_INPUT_EVENTS_LONG
|
||||
)
|
||||
assert rewritten_dialogue == _EXPECTED_REWRITTEN_DIALOGUE_LONG
|
||||
|
||||
def test_summarize_conversation_with_function_calls(self):
|
||||
"""Tests _summarize_conversation with include_function_calls=True."""
|
||||
rewritten_dialogue = LlmBackedUserSimulator._summarize_conversation(
|
||||
_INPUT_EVENTS, include_function_calls=True
|
||||
)
|
||||
expected = (
|
||||
"user: Can you help me?\n\n"
|
||||
"helpful_assistant called tool 'get_user_name' with args: None\n\n"
|
||||
"Tool 'get_user_name' returned: {'name': 'John Doe'}\n\n"
|
||||
"helpful_assistant: Hi John, what can I do for you?"
|
||||
)
|
||||
assert rewritten_dialogue == expected
|
||||
|
||||
|
||||
async def to_async_iter(items):
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_agent(mocker):
|
||||
"""Provides a mock LLM agent."""
|
||||
mock_llm_registry_cls = mocker.patch(
|
||||
"google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry",
|
||||
autospec=True,
|
||||
)
|
||||
mock_llm_registry = mocker.MagicMock()
|
||||
mock_llm_registry_cls.return_value = mock_llm_registry
|
||||
mock_agent = mocker.MagicMock()
|
||||
mock_llm_registry.resolve.return_value.return_value = mock_agent
|
||||
return mock_agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_scenario():
|
||||
"""Provides a test conversation scenario."""
|
||||
return conversation_scenarios.ConversationScenario(
|
||||
starting_prompt="Hello", conversation_plan="test plan"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_persona():
|
||||
"""Provides a test user persona."""
|
||||
return UserPersona(
|
||||
id="test_persona",
|
||||
description="A test persona",
|
||||
behaviors=[
|
||||
UserBehavior(
|
||||
name="polite",
|
||||
description="is polite",
|
||||
behavior_instructions=["Always say please and thank you."],
|
||||
violation_rubrics=["is rude"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_scenario_with_persona(user_persona):
|
||||
"""Provides a test conversation scenario with a user persona."""
|
||||
return conversation_scenarios.ConversationScenario(
|
||||
starting_prompt="Hello",
|
||||
conversation_plan="test plan with persona",
|
||||
user_persona=user_persona,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simulator(mock_llm_agent, conversation_scenario):
|
||||
"""Provides an LlmBackedUserSimulator instance for testing."""
|
||||
config = LlmBackedUserSimulatorConfig(
|
||||
model="test-model",
|
||||
)
|
||||
sim = LlmBackedUserSimulator(
|
||||
config=config, conversation_scenario=conversation_scenario
|
||||
)
|
||||
sim._invocation_count = 1 # Bypass starting prompt by default for tests
|
||||
return sim
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simulator_with_persona(mock_llm_agent, conversation_scenario_with_persona):
|
||||
"""Provides an LlmBackedUserSimulator instance for testing."""
|
||||
config = LlmBackedUserSimulatorConfig(
|
||||
model="test-model",
|
||||
)
|
||||
sim = LlmBackedUserSimulator(
|
||||
config=config, conversation_scenario=conversation_scenario_with_persona
|
||||
)
|
||||
sim._invocation_count = 1 # Bypass starting prompt by default for tests
|
||||
return sim
|
||||
|
||||
|
||||
class TestLlmBackedUserSimulator:
|
||||
"""Test cases for LlmBackedUserSimulator main methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_llm_response_return_value(
|
||||
self, simulator, mock_llm_agent, mocker
|
||||
):
|
||||
"""Tests that _get_llm_response returns the full response correctly."""
|
||||
mock_llm_response = mocker.create_autospec(
|
||||
types.GenerateContentResponse, instance=True
|
||||
)
|
||||
mock_llm_response.error_code = None
|
||||
mock_llm_response.content = types.Content(
|
||||
parts=[
|
||||
types.Part(text="some thought", thought=True),
|
||||
types.Part(text="Hello world!"),
|
||||
]
|
||||
)
|
||||
mock_llm_response.parts = mock_llm_response.content.parts
|
||||
mock_llm_agent.generate_content_async.return_value = to_async_iter(
|
||||
[mock_llm_response]
|
||||
)
|
||||
response, error_reason = await simulator._get_llm_response(
|
||||
rewritten_dialogue=""
|
||||
)
|
||||
assert response == "Hello world!"
|
||||
assert error_reason is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_user_message_first_invocation(
|
||||
self, simulator, mock_llm_agent, conversation_scenario
|
||||
):
|
||||
"""Tests that the first invocation returns the starting prompt."""
|
||||
simulator._invocation_count = 0 # override testing default
|
||||
next_user_message = await simulator.get_next_user_message(events=[])
|
||||
|
||||
expected_user_message = types.Content(
|
||||
parts=[types.Part(text=conversation_scenario.starting_prompt)],
|
||||
role="user",
|
||||
)
|
||||
assert next_user_message.status == Status.SUCCESS
|
||||
assert next_user_message.user_message == expected_user_message
|
||||
mock_llm_agent.generate_content_async.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_limit_reached(self, conversation_scenario):
|
||||
"""Tests get_next_user_message when the turn limit is reached."""
|
||||
config = LlmBackedUserSimulatorConfig(
|
||||
max_allowed_invocations=1,
|
||||
)
|
||||
simulator = LlmBackedUserSimulator(
|
||||
config=config, conversation_scenario=conversation_scenario
|
||||
)
|
||||
simulator._invocation_count = 1
|
||||
|
||||
next_user_message = await simulator.get_next_user_message(
|
||||
events=_INPUT_EVENTS
|
||||
)
|
||||
|
||||
assert next_user_message.status == Status.TURN_LIMIT_REACHED
|
||||
assert next_user_message.user_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_signal_detected(self, simulator, mock_llm_agent, mocker):
|
||||
"""Tests get_next_user_message when the stop signal is detected."""
|
||||
mock_llm_response = mocker.create_autospec(
|
||||
types.GenerateContentResponse, instance=True
|
||||
)
|
||||
mock_llm_response.error_code = None
|
||||
mock_llm_response.content = types.Content(
|
||||
parts=[types.Part(text="Thanks! Bye!</finished>")]
|
||||
)
|
||||
mock_llm_response.parts = mock_llm_response.content.parts
|
||||
mock_llm_agent.generate_content_async.return_value = to_async_iter(
|
||||
[mock_llm_response]
|
||||
)
|
||||
|
||||
next_user_message = await simulator.get_next_user_message(
|
||||
events=_INPUT_EVENTS
|
||||
)
|
||||
|
||||
assert next_user_message.status == Status.STOP_SIGNAL_DETECTED
|
||||
assert next_user_message.user_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_message_generated_empty_response(
|
||||
self, simulator, mock_llm_agent
|
||||
):
|
||||
"""Tests get_next_user_message when no message is generated (empty stream)."""
|
||||
mock_llm_agent.generate_content_async.return_value = to_async_iter([])
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Failed to generate a user message: LLM returned empty response",
|
||||
):
|
||||
await simulator.get_next_user_message(events=_INPUT_EVENTS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_user_message_safety_blocked(
|
||||
self, simulator, mock_llm_agent, mocker
|
||||
):
|
||||
"""Tests get_next_user_message when response is safety blocked."""
|
||||
mock_llm_response = mocker.create_autospec(
|
||||
types.GenerateContentResponse, instance=True
|
||||
)
|
||||
mock_llm_response.content = None
|
||||
mock_llm_response.error_code = "SAFETY"
|
||||
mock_llm_response.error_message = "Blocked by safety"
|
||||
mock_llm_response.parts = []
|
||||
mock_llm_agent.generate_content_async.return_value = to_async_iter(
|
||||
[mock_llm_response]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match=(
|
||||
"Failed to generate a user message: safety filters or other error"
|
||||
" \\(code=SAFETY\\)"
|
||||
),
|
||||
):
|
||||
await simulator.get_next_user_message(events=_INPUT_EVENTS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_user_message_thinking_only(
|
||||
self, simulator, mock_llm_agent, mocker
|
||||
):
|
||||
"""Tests get_next_user_message when response contains only thinking tokens."""
|
||||
mock_llm_response = mocker.create_autospec(
|
||||
types.GenerateContentResponse, instance=True
|
||||
)
|
||||
mock_llm_response.content = types.Content(
|
||||
parts=[
|
||||
types.Part(text="thinking...", thought=True),
|
||||
]
|
||||
)
|
||||
mock_llm_response.error_code = None
|
||||
mock_llm_response.parts = mock_llm_response.content.parts
|
||||
mock_llm_agent.generate_content_async.return_value = to_async_iter(
|
||||
[mock_llm_response]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match=(
|
||||
"Failed to generate a user message: LLM returned only thinking"
|
||||
" tokens"
|
||||
),
|
||||
):
|
||||
await simulator.get_next_user_message(events=_INPUT_EVENTS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_user_message_success(
|
||||
self, simulator, mock_llm_agent, mocker
|
||||
):
|
||||
"""Tests get_next_user_message when the user message is generated successfully."""
|
||||
mock_llm_response = mocker.create_autospec(
|
||||
types.GenerateContentResponse, instance=True
|
||||
)
|
||||
mock_llm_response.error_code = None
|
||||
mock_llm_response.content = types.Content(
|
||||
parts=[types.Part(text="I need to book a flight.")]
|
||||
)
|
||||
mock_llm_response.parts = mock_llm_response.content.parts
|
||||
mock_llm_agent.generate_content_async.return_value = to_async_iter(
|
||||
[mock_llm_response]
|
||||
)
|
||||
|
||||
next_user_message = await simulator.get_next_user_message(
|
||||
events=_INPUT_EVENTS
|
||||
)
|
||||
|
||||
expected_user_message = types.Content(
|
||||
parts=[types.Part(text="I need to book a flight.")], role="user"
|
||||
)
|
||||
|
||||
assert next_user_message.status == Status.SUCCESS
|
||||
assert next_user_message.user_message == expected_user_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_user_message_with_persona_success(
|
||||
self, simulator_with_persona, mock_llm_agent, mocker
|
||||
):
|
||||
"""Tests get_next_user_message when the user message is generated successfully."""
|
||||
mock_llm_response = mocker.create_autospec(
|
||||
types.GenerateContentResponse, instance=True
|
||||
)
|
||||
mock_llm_response.error_code = None
|
||||
mock_llm_response.content = types.Content(
|
||||
parts=[types.Part(text="I need to book a flight.")]
|
||||
)
|
||||
mock_llm_response.parts = mock_llm_response.content.parts
|
||||
mock_llm_agent.generate_content_async.return_value = to_async_iter(
|
||||
[mock_llm_response]
|
||||
)
|
||||
|
||||
next_user_message = await simulator_with_persona.get_next_user_message(
|
||||
events=_INPUT_EVENTS
|
||||
)
|
||||
|
||||
expected_user_message = types.Content(
|
||||
parts=[types.Part(text="I need to book a flight.")], role="user"
|
||||
)
|
||||
|
||||
assert next_user_message.status == Status.SUCCESS
|
||||
assert next_user_message.user_message == expected_user_message
|
||||
@@ -0,0 +1,280 @@
|
||||
# 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.
|
||||
|
||||
import textwrap
|
||||
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _get_user_simulator_instructions_template
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import _USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import get_llm_backed_user_simulator_prompt
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import is_valid_user_simulator_template
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
|
||||
from jinja2.exceptions import SecurityError
|
||||
import pytest
|
||||
|
||||
_MOCK_DEFAULT_TEMPLATE = textwrap.dedent("""\
|
||||
Default template
|
||||
|
||||
# Conversation Plan
|
||||
{{conversation_plan}}
|
||||
|
||||
# Conversation History
|
||||
{{conversation_history}}
|
||||
|
||||
# Stop signal
|
||||
{{stop_signal}}
|
||||
""").strip()
|
||||
|
||||
_MOCK_PERSONA_TEMPLATE = textwrap.dedent("""\
|
||||
Persona template
|
||||
|
||||
# Persona Description
|
||||
{{persona.description}}
|
||||
{% for b in persona.behaviors %}
|
||||
## {{ b.name }}
|
||||
{{ b.description }}
|
||||
|
||||
Instructions:
|
||||
{{ b.get_behavior_instructions_str() }}
|
||||
{% endfor %}
|
||||
# Conversation Plan
|
||||
{{conversation_plan}}
|
||||
|
||||
# Conversation History
|
||||
{{conversation_history}}
|
||||
|
||||
# Stop signal
|
||||
{{stop_signal}}
|
||||
""").strip()
|
||||
|
||||
|
||||
class TestGetUserSimulatorInstructionsTemplate:
|
||||
"""Test cases for _get_user_simulator_instructions_template."""
|
||||
|
||||
def test_get_user_simulator_instructions_template_default(self):
|
||||
assert (
|
||||
_get_user_simulator_instructions_template()
|
||||
== _DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE
|
||||
)
|
||||
|
||||
def test_get_user_simulator_instructions_template_with_custom_instructions(
|
||||
self,
|
||||
):
|
||||
custom_instructions = "custom instructions"
|
||||
assert (
|
||||
_get_user_simulator_instructions_template(
|
||||
custom_instructions=custom_instructions
|
||||
)
|
||||
== custom_instructions
|
||||
)
|
||||
|
||||
def test_get_user_simulator_instructions_template_with_persona(self):
|
||||
user_persona = UserPersona(
|
||||
id="test_persona", description="Test persona", behaviors=[]
|
||||
)
|
||||
assert (
|
||||
_get_user_simulator_instructions_template(user_persona=user_persona)
|
||||
== _USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE
|
||||
)
|
||||
|
||||
def test_get_user_simulator_instructions_template_with_bad_custom_instructions_raises_error(
|
||||
self,
|
||||
):
|
||||
custom_instructions = "custom instructions"
|
||||
user_persona = UserPersona(
|
||||
id="test_persona", description="Test persona", behaviors=[]
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
_get_user_simulator_instructions_template(
|
||||
custom_instructions=custom_instructions, user_persona=user_persona
|
||||
)
|
||||
|
||||
|
||||
sample_persona = UserPersona(
|
||||
id="test_persona",
|
||||
description="Test persona description",
|
||||
behaviors=[
|
||||
UserBehavior(
|
||||
name="Test behavior",
|
||||
description="Test behavior description",
|
||||
behavior_instructions=["instruction 1", "instruction 2"],
|
||||
violation_rubrics=["rubric 1"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestGetLlmBackedUserSimulatorPrompt:
|
||||
"""Test cases for get_llm_backed_user_simulator_prompt."""
|
||||
|
||||
def test_get_llm_backed_user_simulator_prompt_default(self, mocker):
|
||||
mocker.patch(
|
||||
"google.adk.evaluation.simulation.llm_backed_user_simulator_prompts._DEFAULT_USER_SIMULATOR_INSTRUCTIONS_TEMPLATE",
|
||||
_MOCK_DEFAULT_TEMPLATE,
|
||||
)
|
||||
prompt = get_llm_backed_user_simulator_prompt(
|
||||
conversation_plan="test plan",
|
||||
conversation_history="test history",
|
||||
stop_signal="test stop",
|
||||
)
|
||||
expected_prompt = textwrap.dedent("""\
|
||||
Default template
|
||||
|
||||
# Conversation Plan
|
||||
test plan
|
||||
|
||||
# Conversation History
|
||||
test history
|
||||
|
||||
# Stop signal
|
||||
test stop""").strip()
|
||||
|
||||
assert prompt == expected_prompt
|
||||
|
||||
def test_get_llm_backed_user_simulator_prompt_with_custom_instructions(self):
|
||||
custom_instructions = textwrap.dedent("""\
|
||||
Custom instructions:
|
||||
|
||||
# Past history
|
||||
{{conversation_plan}}
|
||||
|
||||
# Plan
|
||||
{{conversation_plan}}
|
||||
|
||||
# Finished!
|
||||
{{stop_signal}}""").strip()
|
||||
prompt = get_llm_backed_user_simulator_prompt(
|
||||
conversation_plan="test plan",
|
||||
conversation_history="test history",
|
||||
stop_signal="test stop",
|
||||
custom_instructions=custom_instructions,
|
||||
)
|
||||
|
||||
expected_prompt = textwrap.dedent("""\
|
||||
Custom instructions:
|
||||
|
||||
# Past history
|
||||
test plan
|
||||
|
||||
# Plan
|
||||
test plan
|
||||
|
||||
# Finished!
|
||||
test stop""").strip()
|
||||
assert prompt == expected_prompt
|
||||
|
||||
def test_get_llm_backed_user_simulator_prompt_with_persona(self, mocker):
|
||||
mocker.patch(
|
||||
"google.adk.evaluation.simulation.llm_backed_user_simulator_prompts._USER_SIMULATOR_INSTRUCTIONS_WITH_PERSONA_TEMPLATE",
|
||||
_MOCK_PERSONA_TEMPLATE,
|
||||
)
|
||||
prompt = get_llm_backed_user_simulator_prompt(
|
||||
conversation_plan="test plan",
|
||||
conversation_history="test history",
|
||||
stop_signal="test stop",
|
||||
user_persona=sample_persona,
|
||||
)
|
||||
expected_prompt = textwrap.dedent("""\
|
||||
Persona template
|
||||
|
||||
# Persona Description
|
||||
Test persona description
|
||||
|
||||
## Test behavior
|
||||
Test behavior description
|
||||
|
||||
Instructions:
|
||||
* instruction 1
|
||||
* instruction 2
|
||||
|
||||
# Conversation Plan
|
||||
test plan
|
||||
|
||||
# Conversation History
|
||||
test history
|
||||
|
||||
# Stop signal
|
||||
test stop""").strip()
|
||||
assert prompt == expected_prompt
|
||||
|
||||
def test_get_llm_backed_user_simulator_prompt_renders_persona_templates_in_sandbox(
|
||||
self,
|
||||
):
|
||||
user_persona = UserPersona(
|
||||
id="test_persona",
|
||||
description="Test persona description",
|
||||
behaviors=[
|
||||
UserBehavior(
|
||||
name="Behavior {{ stop_signal }}",
|
||||
description="Description {{ stop_signal }}",
|
||||
behavior_instructions=["instruction {{ stop_signal }}"],
|
||||
violation_rubrics=["rubric 1"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
prompt = get_llm_backed_user_simulator_prompt(
|
||||
conversation_plan="test plan",
|
||||
conversation_history="test history",
|
||||
stop_signal="test stop",
|
||||
user_persona=user_persona,
|
||||
)
|
||||
|
||||
assert "## Behavior test stop" in prompt
|
||||
assert "Description test stop" in prompt
|
||||
assert " * instruction test stop" in prompt
|
||||
|
||||
def test_get_llm_backed_user_simulator_prompt_blocks_unsafe_persona_templates(
|
||||
self,
|
||||
):
|
||||
user_persona = UserPersona(
|
||||
id="test_persona",
|
||||
description="Test persona description",
|
||||
behaviors=[
|
||||
UserBehavior(
|
||||
name="{{ ''.__class__.__mro__ }}",
|
||||
description="Test behavior description",
|
||||
behavior_instructions=["instruction 1"],
|
||||
violation_rubrics=["rubric 1"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(SecurityError):
|
||||
get_llm_backed_user_simulator_prompt(
|
||||
conversation_plan="test plan",
|
||||
conversation_history="test history",
|
||||
stop_signal="test stop",
|
||||
user_persona=user_persona,
|
||||
)
|
||||
|
||||
|
||||
class TestIsValidUserSimulatorTemplate:
|
||||
"""Test cases for is_valid_user_simulator_template."""
|
||||
|
||||
def test_valid_template(self):
|
||||
template = "Hello {{ name }}"
|
||||
params = ["name"]
|
||||
assert is_valid_user_simulator_template(template, params) is True
|
||||
|
||||
def test_invalid_syntax(self):
|
||||
template = "Hello {{ name"
|
||||
params = ["name"]
|
||||
assert is_valid_user_simulator_template(template, params) is False
|
||||
|
||||
def test_missing_parameter(self):
|
||||
template = "Hello"
|
||||
params = ["name"]
|
||||
assert is_valid_user_simulator_template(template, params) is False
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
# 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.
|
||||
|
||||
import textwrap
|
||||
|
||||
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _get_latest_turn_user_simulator_quality_prompt_template
|
||||
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE
|
||||
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import _LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE
|
||||
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import get_per_turn_user_simulator_quality_prompt
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
|
||||
from jinja2.exceptions import SecurityError
|
||||
import pytest
|
||||
|
||||
_MOCK_DEFAULT_TEMPLATE = textwrap.dedent("""\
|
||||
Default template
|
||||
|
||||
# Conversation Plan
|
||||
{{conversation_plan}}
|
||||
|
||||
# Conversation History
|
||||
{{conversation_history}}
|
||||
|
||||
# Generated User Response
|
||||
{{generated_user_response}}
|
||||
|
||||
# Stop signal
|
||||
{{stop_signal}}
|
||||
""").strip()
|
||||
|
||||
_MOCK_PERSONA_TEMPLATE = textwrap.dedent("""\
|
||||
Persona template
|
||||
|
||||
# Persona Description
|
||||
{{persona.description}}
|
||||
{% for b in persona.behaviors %}
|
||||
## Criteria: {{ b.name | render_string_filter}}
|
||||
{{ b.description | render_string_filter}}
|
||||
|
||||
Mark as FAIL if any of the following Violations occur:
|
||||
{{ b.get_violation_rubrics_str() | render_string_filter}}
|
||||
{% endfor %}
|
||||
# Conversation Plan
|
||||
{{conversation_plan}}
|
||||
|
||||
# Conversation History
|
||||
{{conversation_history}}
|
||||
|
||||
# Generated User Response
|
||||
{{generated_user_response}}
|
||||
|
||||
# Stop signal
|
||||
{{stop_signal}}
|
||||
""").strip()
|
||||
|
||||
|
||||
class TestGetLatestTurnUserSimulatorQualityPrompt:
|
||||
"""Test cases for get_latest_turn_user_simulator_quality_prompt."""
|
||||
|
||||
def test_get_get_latest_turn_user_simulator_quality_prompt_template_default(
|
||||
self,
|
||||
):
|
||||
prompt = _get_latest_turn_user_simulator_quality_prompt_template(
|
||||
user_persona=None
|
||||
)
|
||||
assert prompt == _LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE
|
||||
|
||||
def test_get_latest_turn_user_simulator_quality_prompt_template_with_persona(
|
||||
self,
|
||||
):
|
||||
"""Tests that the correct prompt is returned when a persona is provided."""
|
||||
persona = UserPersona(
|
||||
id="test_persona",
|
||||
description="Test persona description.",
|
||||
behaviors=[
|
||||
UserBehavior(
|
||||
name="test_behavior",
|
||||
description="Test behavior description.",
|
||||
behavior_instructions=["instruction1"],
|
||||
violation_rubrics=["violation1"],
|
||||
)
|
||||
],
|
||||
)
|
||||
prompt = _get_latest_turn_user_simulator_quality_prompt_template(
|
||||
user_persona=persona
|
||||
)
|
||||
assert (
|
||||
prompt
|
||||
== _LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE
|
||||
)
|
||||
|
||||
|
||||
class TestGetPerTurnUserSimulatorQualityPrompt:
|
||||
"""Test cases for get_per_turn_user_simulator_quality_prompt."""
|
||||
|
||||
def test_get_per_turn_user_simulator_quality_prompt_default(self, mocker):
|
||||
"""Tests that the correct prompt is returned when no persona is provided."""
|
||||
mocker.patch(
|
||||
"google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts._LATEST_TURN_USER_SIMULATOR_EVALUATOR_PROMPT_TEMPLATE",
|
||||
_MOCK_DEFAULT_TEMPLATE,
|
||||
)
|
||||
prompt = get_per_turn_user_simulator_quality_prompt(
|
||||
conversation_plan="plan",
|
||||
conversation_history="history",
|
||||
generated_user_response="response",
|
||||
stop_signal="stop",
|
||||
user_persona=None,
|
||||
)
|
||||
expected_prompt = textwrap.dedent("""\
|
||||
Default template
|
||||
|
||||
# Conversation Plan
|
||||
plan
|
||||
|
||||
# Conversation History
|
||||
history
|
||||
|
||||
# Generated User Response
|
||||
response
|
||||
|
||||
# Stop signal
|
||||
stop""").strip()
|
||||
assert prompt == expected_prompt
|
||||
|
||||
def test_get_per_turn_user_simulator_quality_prompt_with_persona(
|
||||
self, mocker
|
||||
):
|
||||
"""Tests that the correct prompt is returned when a persona is provided."""
|
||||
mocker.patch(
|
||||
"google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts._LATEST_TURN_USER_SIMULATOR_WITH_PERSONA_EVALUATOR_PROMPT_TEMPLATE",
|
||||
_MOCK_PERSONA_TEMPLATE,
|
||||
)
|
||||
persona = UserPersona(
|
||||
id="test_persona",
|
||||
description="Test persona description.",
|
||||
behaviors=[
|
||||
UserBehavior(
|
||||
name="test_behavior",
|
||||
description="Test behavior description.",
|
||||
behavior_instructions=["instruction1"],
|
||||
violation_rubrics=["violation1"],
|
||||
)
|
||||
],
|
||||
)
|
||||
prompt = get_per_turn_user_simulator_quality_prompt(
|
||||
conversation_plan="plan",
|
||||
conversation_history="history",
|
||||
generated_user_response="response",
|
||||
stop_signal="stop",
|
||||
user_persona=persona,
|
||||
)
|
||||
expected_prompt = textwrap.dedent("""\
|
||||
Persona template
|
||||
|
||||
# Persona Description
|
||||
Test persona description.
|
||||
|
||||
## Criteria: test_behavior
|
||||
Test behavior description.
|
||||
|
||||
Mark as FAIL if any of the following Violations occur:
|
||||
* violation1
|
||||
|
||||
# Conversation Plan
|
||||
plan
|
||||
|
||||
# Conversation History
|
||||
history
|
||||
|
||||
# Generated User Response
|
||||
response
|
||||
|
||||
# Stop signal
|
||||
stop""").strip()
|
||||
assert prompt == expected_prompt
|
||||
|
||||
def test_get_per_turn_user_simulator_quality_prompt_renders_persona_templates_in_sandbox(
|
||||
self,
|
||||
):
|
||||
persona = UserPersona(
|
||||
id="test_persona",
|
||||
description="Test persona description.",
|
||||
behaviors=[
|
||||
UserBehavior(
|
||||
name="criteria {{ stop_signal }}",
|
||||
description="Test behavior {{ stop_signal }}.",
|
||||
behavior_instructions=["instruction1"],
|
||||
violation_rubrics=["violation {{ stop_signal }}"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
prompt = get_per_turn_user_simulator_quality_prompt(
|
||||
conversation_plan="plan",
|
||||
conversation_history="history",
|
||||
generated_user_response="response",
|
||||
stop_signal="stop",
|
||||
user_persona=persona,
|
||||
)
|
||||
|
||||
assert "## Criteria: criteria stop" in prompt
|
||||
assert "Test behavior stop." in prompt
|
||||
assert " * violation stop" in prompt
|
||||
|
||||
def test_get_per_turn_user_simulator_quality_prompt_blocks_unsafe_persona_templates(
|
||||
self,
|
||||
):
|
||||
persona = UserPersona(
|
||||
id="test_persona",
|
||||
description="Test persona description.",
|
||||
behaviors=[
|
||||
UserBehavior(
|
||||
name="{{ ''.__class__.__mro__ }}",
|
||||
description="Test behavior description.",
|
||||
behavior_instructions=["instruction1"],
|
||||
violation_rubrics=["violation1"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(SecurityError):
|
||||
get_per_turn_user_simulator_quality_prompt(
|
||||
conversation_plan="plan",
|
||||
conversation_history="history",
|
||||
generated_user_response="response",
|
||||
stop_signal="stop",
|
||||
user_persona=persona,
|
||||
)
|
||||
@@ -0,0 +1,698 @@
|
||||
# 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
|
||||
|
||||
from google.adk.evaluation.eval_case import ConversationScenario
|
||||
from google.adk.evaluation.eval_case import Invocation
|
||||
from google.adk.evaluation.eval_metrics import EvalMetric
|
||||
from google.adk.evaluation.eval_metrics import EvalStatus
|
||||
from google.adk.evaluation.eval_metrics import JudgeModelOptions
|
||||
from google.adk.evaluation.eval_metrics import LlmBackedUserSimulatorCriterion
|
||||
from google.adk.evaluation.evaluator import PerInvocationResult
|
||||
from google.adk.evaluation.llm_as_judge import AutoRaterScore
|
||||
from google.adk.evaluation.llm_as_judge_utils import Label
|
||||
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import _format_conversation_history
|
||||
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import _parse_llm_response
|
||||
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_v1 import PerTurnUserSimulatorQualityV1
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
|
||||
from google.adk.models.llm_response import LlmResponse
|
||||
from google.genai import types
|
||||
from google.genai import types as genai_types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_text",
|
||||
[
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": True
|
||||
}
|
||||
],
|
||||
"is_valid_undefined_key": True
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": True
|
||||
}
|
||||
],
|
||||
"is_valid": "undefined label",
|
||||
}
|
||||
```""",
|
||||
],
|
||||
)
|
||||
def test_parse_llm_response_label_not_found(response_text):
|
||||
label = _parse_llm_response(response_text)
|
||||
assert label == Label.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_text",
|
||||
[
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": True
|
||||
}
|
||||
],
|
||||
"is_valid": True
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": True
|
||||
}
|
||||
],
|
||||
"is_valid": "true"
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": True
|
||||
}
|
||||
],
|
||||
"is_valid": "valid"
|
||||
}
|
||||
```""",
|
||||
],
|
||||
)
|
||||
def test_parse_llm_response_label_valid(response_text):
|
||||
label = _parse_llm_response(response_text)
|
||||
assert label == Label.VALID
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_text",
|
||||
[
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": False
|
||||
}
|
||||
],
|
||||
"is_valid": False
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": False
|
||||
}
|
||||
],
|
||||
"is_valid": "false",
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": False
|
||||
}
|
||||
],
|
||||
"is_valid": "invalid",
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": False
|
||||
}
|
||||
],
|
||||
"is_valid": "almost",
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": False
|
||||
}
|
||||
],
|
||||
"is_valid": "partially_valid",
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": False
|
||||
}
|
||||
],
|
||||
"is_valid": "partially valid",
|
||||
}
|
||||
```""",
|
||||
"""```json
|
||||
{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "TEST_NAME",
|
||||
"reasoning": "test_resonining",
|
||||
"passes": False
|
||||
}
|
||||
],
|
||||
"is_valid": "partially",
|
||||
}
|
||||
```""",
|
||||
],
|
||||
)
|
||||
def test_parse_llm_response_label_invalid(response_text):
|
||||
label = _parse_llm_response(response_text)
|
||||
assert label == Label.INVALID
|
||||
|
||||
|
||||
def create_test_template() -> str:
|
||||
return """This is a test template with stop signal: `{{stop_signal}}`.
|
||||
|
||||
# Conversation Plan
|
||||
{{conversation_plan}}
|
||||
|
||||
# Conversation History
|
||||
{{conversation_history}}
|
||||
|
||||
# Generated User Response
|
||||
{{generated_user_response}}
|
||||
""".strip()
|
||||
|
||||
|
||||
def _create_test_evaluator(
|
||||
threshold: float = 1.0, stop_signal: str = "test stop signal"
|
||||
) -> PerTurnUserSimulatorQualityV1:
|
||||
evaluator = PerTurnUserSimulatorQualityV1(
|
||||
EvalMetric(
|
||||
metric_name="test_per_turn_user_simulator_quality_v1",
|
||||
threshold=threshold,
|
||||
criterion=LlmBackedUserSimulatorCriterion(
|
||||
threshold=threshold,
|
||||
stop_signal=stop_signal,
|
||||
judge_model_options=JudgeModelOptions(
|
||||
judge_model="gemini-2.5-flash",
|
||||
judge_model_config=genai_types.GenerateContentConfig(),
|
||||
num_samples=3,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return evaluator
|
||||
|
||||
|
||||
def _create_test_conversation_scenario(
|
||||
conversation_plan: str = "test conversation plan",
|
||||
starting_prompt: str = "test starting prompt",
|
||||
user_persona: UserPersona = None,
|
||||
) -> ConversationScenario:
|
||||
"""Returns a ConversationScenario."""
|
||||
return ConversationScenario(
|
||||
starting_prompt=starting_prompt,
|
||||
conversation_plan=conversation_plan,
|
||||
user_persona=user_persona,
|
||||
)
|
||||
|
||||
|
||||
def _create_test_invocation(
|
||||
invocation_id: str,
|
||||
user_content: str = "user content",
|
||||
model_content: str = "model content",
|
||||
) -> Invocation:
|
||||
return Invocation(
|
||||
invocation_id=invocation_id,
|
||||
user_content=genai_types.Content(
|
||||
parts=[genai_types.Part(text=user_content)],
|
||||
role="user",
|
||||
),
|
||||
final_response=genai_types.Content(
|
||||
parts=[genai_types.Part(text=model_content)],
|
||||
role="model",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_invocations(
|
||||
conversation_history: list[str],
|
||||
) -> list[Invocation]:
|
||||
conversation_length = len(conversation_history)
|
||||
|
||||
assert conversation_length % 2 == 0
|
||||
|
||||
invocations = []
|
||||
for i in range(conversation_length // 2):
|
||||
user_message = conversation_history[2 * i]
|
||||
model_message = conversation_history[2 * i + 1]
|
||||
|
||||
invocations.append(
|
||||
_create_test_invocation(
|
||||
"turn {i}", user_content=user_message, model_content=model_message
|
||||
)
|
||||
)
|
||||
|
||||
return invocations
|
||||
|
||||
|
||||
def test_format_llm_prompt_raises_error_if_previous_invocations_is_none():
|
||||
evaluator = _create_test_evaluator()
|
||||
with pytest.raises(
|
||||
ValueError, match="Previous invocations should have a set value"
|
||||
):
|
||||
evaluator._format_llm_prompt(
|
||||
invocation=_create_test_invocation("1"),
|
||||
conversation_scenario=_create_test_conversation_scenario(),
|
||||
previous_invocations=None,
|
||||
)
|
||||
|
||||
|
||||
def test_format_llm_prompt_raises_error_if_conversation_scenario_is_none():
|
||||
evaluator = _create_test_evaluator()
|
||||
with pytest.raises(
|
||||
ValueError, match="Conversation scenario should have a set value"
|
||||
):
|
||||
evaluator._format_llm_prompt(
|
||||
invocation=_create_test_invocation("1"),
|
||||
conversation_scenario=None,
|
||||
previous_invocations=[],
|
||||
)
|
||||
|
||||
|
||||
def test_convert_llm_response_to_score_pass():
|
||||
evaluator = _create_test_evaluator()
|
||||
auto_rater_response = """```json
|
||||
{
|
||||
"is_valid": True,
|
||||
}
|
||||
```"""
|
||||
llm_response = LlmResponse(
|
||||
content=genai_types.Content(
|
||||
parts=[genai_types.Part(text=auto_rater_response)],
|
||||
role="model",
|
||||
)
|
||||
)
|
||||
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
|
||||
assert auto_rater_score == AutoRaterScore(score=1.0)
|
||||
|
||||
|
||||
def test_convert_llm_response_to_score_failure():
|
||||
evaluator = _create_test_evaluator()
|
||||
auto_rater_response = """```json
|
||||
{
|
||||
"is_valid": False,
|
||||
}
|
||||
```"""
|
||||
llm_response = LlmResponse(
|
||||
content=genai_types.Content(
|
||||
parts=[genai_types.Part(text=auto_rater_response)],
|
||||
role="model",
|
||||
)
|
||||
)
|
||||
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
|
||||
assert auto_rater_score == AutoRaterScore(score=0.0)
|
||||
|
||||
|
||||
def test_convert_llm_response_to_score_invalid_json():
|
||||
evaluator = _create_test_evaluator()
|
||||
llm_response = LlmResponse(
|
||||
content=genai_types.Content(
|
||||
parts=[genai_types.Part(text="invalid json")],
|
||||
role="model",
|
||||
)
|
||||
)
|
||||
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
|
||||
assert auto_rater_score == AutoRaterScore()
|
||||
|
||||
|
||||
def test_convert_llm_response_to_score_missing_key():
|
||||
evaluator = _create_test_evaluator()
|
||||
llm_response = LlmResponse(
|
||||
content=genai_types.Content(
|
||||
parts=[genai_types.Part(text="{}")],
|
||||
role="model",
|
||||
)
|
||||
)
|
||||
auto_rater_score = evaluator._convert_llm_response_to_score(llm_response)
|
||||
assert auto_rater_score == AutoRaterScore()
|
||||
|
||||
|
||||
def test_aggregate_samples_not_evaluated():
|
||||
evaluator = _create_test_evaluator()
|
||||
samples = [
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("1"),
|
||||
score=None,
|
||||
eval_status=EvalStatus.NOT_EVALUATED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("2"),
|
||||
score=None,
|
||||
eval_status=EvalStatus.NOT_EVALUATED,
|
||||
),
|
||||
]
|
||||
|
||||
aggregation = evaluator._aggregate_samples(samples)
|
||||
assert aggregation == samples[0]
|
||||
|
||||
|
||||
def test_aggregate_samples_pass():
|
||||
evaluator = _create_test_evaluator()
|
||||
# The majority of results should be positive.
|
||||
samples = [
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("1"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("2"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("3"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.FAILED,
|
||||
),
|
||||
]
|
||||
|
||||
aggregation_result = evaluator._aggregate_samples(samples)
|
||||
|
||||
assert aggregation_result.score == 1.0
|
||||
assert aggregation_result.eval_status == EvalStatus.PASSED
|
||||
|
||||
|
||||
def test_aggregate_samples_failure():
|
||||
evaluator = _create_test_evaluator()
|
||||
|
||||
# The majority of results should be negative.
|
||||
samples = [
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("1"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("2"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.FAILED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("3"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.FAILED,
|
||||
),
|
||||
]
|
||||
|
||||
aggregation_result = evaluator._aggregate_samples(samples)
|
||||
|
||||
assert aggregation_result.score == 0.0
|
||||
assert aggregation_result.eval_status == EvalStatus.FAILED
|
||||
|
||||
|
||||
def test_format_conversation_history_with_none_values():
|
||||
"""Tests that _format_conversation_history handles None values."""
|
||||
invocations = [
|
||||
Invocation(
|
||||
invocation_id="1",
|
||||
user_content=types.Content(),
|
||||
final_response=None,
|
||||
)
|
||||
]
|
||||
formatted_history = _format_conversation_history(invocations)
|
||||
assert formatted_history == ""
|
||||
|
||||
|
||||
def test_format_conversation_history():
|
||||
conversation_history = [
|
||||
"first user prompt.",
|
||||
"first agent response.",
|
||||
"second user prompt.",
|
||||
"second agent response.",
|
||||
]
|
||||
invocation_history = _create_test_invocations(conversation_history)
|
||||
formatted_history = _format_conversation_history(invocation_history)
|
||||
assert formatted_history == """user: first user prompt.
|
||||
|
||||
model: first agent response.
|
||||
|
||||
user: second user prompt.
|
||||
|
||||
model: second agent response."""
|
||||
|
||||
|
||||
def test_evaluate_first_turn_pass():
|
||||
evaluator = _create_test_evaluator(
|
||||
threshold=0.8, stop_signal="test stop signal"
|
||||
)
|
||||
conversation_scenario = _create_test_conversation_scenario(
|
||||
conversation_plan="plan",
|
||||
starting_prompt="test starting prompt",
|
||||
)
|
||||
invocation = _create_test_invocation("1", user_content="test starting prompt")
|
||||
|
||||
result = evaluator._evaluate_first_turn(invocation, conversation_scenario)
|
||||
|
||||
assert result.score == 1.0
|
||||
assert result.eval_status == EvalStatus.PASSED
|
||||
|
||||
|
||||
def test_evaluate_first_turn_failure():
|
||||
evaluator = _create_test_evaluator(
|
||||
threshold=1.0, stop_signal="test stop signal"
|
||||
)
|
||||
conversation_scenario = _create_test_conversation_scenario(
|
||||
conversation_plan="plan",
|
||||
starting_prompt="test starting prompt",
|
||||
)
|
||||
invocation = _create_test_invocation("1", "wrong starting prompt")
|
||||
|
||||
result = evaluator._evaluate_first_turn(invocation, conversation_scenario)
|
||||
|
||||
assert result.score == 0.0
|
||||
assert result.eval_status == EvalStatus.FAILED
|
||||
|
||||
|
||||
def test_aggregate_conversation_results_all_pass_produces_pass():
|
||||
evaluator = _create_test_evaluator()
|
||||
results = [
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("1"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("2"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("3"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("4"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
]
|
||||
aggregation = evaluator._aggregate_conversation_results(results)
|
||||
assert aggregation.overall_score == 1.0
|
||||
assert aggregation.overall_eval_status == EvalStatus.PASSED
|
||||
|
||||
|
||||
def test_aggregate_conversation_results_percentage_above_threshold_produces_pass():
|
||||
evaluator = _create_test_evaluator(threshold=0.7)
|
||||
results = [
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("1"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("2"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("3"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("4"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
]
|
||||
aggregation = evaluator._aggregate_conversation_results(results)
|
||||
assert aggregation.overall_score == 0.75
|
||||
assert aggregation.overall_eval_status == EvalStatus.PASSED
|
||||
|
||||
|
||||
def test_aggregate_conversation_results_all_failures_produces_failure():
|
||||
evaluator = _create_test_evaluator()
|
||||
results = [
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("1"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.FAILED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("2"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.FAILED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("3"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.FAILED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("4"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.FAILED,
|
||||
),
|
||||
]
|
||||
aggregation = evaluator._aggregate_conversation_results(results)
|
||||
assert aggregation.overall_score == 0.0
|
||||
assert aggregation.overall_eval_status == EvalStatus.FAILED
|
||||
|
||||
|
||||
def test_aggregate_conversation_percentage_below_threshold_produces_failure():
|
||||
evaluator = _create_test_evaluator(threshold=1.0)
|
||||
results = [
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("1"),
|
||||
score=0.0,
|
||||
eval_status=EvalStatus.FAILED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("2"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("3"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
PerInvocationResult(
|
||||
actual_invocation=_create_test_invocation("4"),
|
||||
score=1.0,
|
||||
eval_status=EvalStatus.PASSED,
|
||||
),
|
||||
]
|
||||
aggregation = evaluator._aggregate_conversation_results(results)
|
||||
assert aggregation.overall_score == 0.75
|
||||
assert aggregation.overall_eval_status == EvalStatus.FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_invocations_all_pass():
|
||||
evaluator = _create_test_evaluator()
|
||||
|
||||
async def sample_llm_valid(*args, **kwargs): # pylint: disable=unused-argument
|
||||
return AutoRaterScore(score=1.0)
|
||||
|
||||
evaluator._sample_llm = sample_llm_valid # pylint: disable=protected-access
|
||||
starting_prompt = "first user prompt."
|
||||
conversation_scenario = _create_test_conversation_scenario(
|
||||
starting_prompt=starting_prompt
|
||||
)
|
||||
invocations = _create_test_invocations(
|
||||
[starting_prompt, "model 1.", "user 2.", "model 2."]
|
||||
)
|
||||
result = await evaluator.evaluate_invocations(
|
||||
actual_invocations=invocations,
|
||||
expected_invocations=None,
|
||||
conversation_scenario=conversation_scenario,
|
||||
)
|
||||
|
||||
assert result.overall_score == 1.0
|
||||
assert result.overall_eval_status == EvalStatus.PASSED
|
||||
assert len(result.per_invocation_results) == 2
|
||||
assert result.per_invocation_results[0].score == 1.0
|
||||
assert result.per_invocation_results[1].score == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_invocations_none_judge_model_config():
|
||||
"""Tests evaluation when judge_model_config is None."""
|
||||
evaluator = PerTurnUserSimulatorQualityV1(
|
||||
EvalMetric(
|
||||
metric_name="test_per_turn_user_simulator_quality_v1",
|
||||
threshold=1.0,
|
||||
criterion=LlmBackedUserSimulatorCriterion(
|
||||
threshold=1.0,
|
||||
stop_signal="test stop signal",
|
||||
judge_model_options=JudgeModelOptions(
|
||||
judge_model="gemini-2.5-flash",
|
||||
judge_model_config=None,
|
||||
num_samples=1,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async def sample_llm_valid(*args, **kwargs): # pylint: disable=unused-argument
|
||||
return AutoRaterScore(score=1.0)
|
||||
|
||||
evaluator._sample_llm = sample_llm_valid # pylint: disable=protected-access
|
||||
starting_prompt = "first user prompt."
|
||||
conversation_scenario = _create_test_conversation_scenario(
|
||||
starting_prompt=starting_prompt
|
||||
)
|
||||
invocations = _create_test_invocations(
|
||||
[starting_prompt, "model 1.", "user 2.", "model 2."]
|
||||
)
|
||||
result = await evaluator.evaluate_invocations(
|
||||
actual_invocations=invocations,
|
||||
expected_invocations=None,
|
||||
conversation_scenario=conversation_scenario,
|
||||
)
|
||||
|
||||
assert result.overall_score == 1.0
|
||||
assert result.overall_eval_status == EvalStatus.PASSED
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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 google.adk.evaluation.simulation.pre_built_personas import get_default_persona_registry
|
||||
|
||||
|
||||
def test_get_default_persona_registry():
|
||||
"""Tests that the default persona registry can be loaded."""
|
||||
assert get_default_persona_registry() is not None
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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
|
||||
|
||||
from google.adk.evaluation.eval_case import Invocation
|
||||
from google.adk.evaluation.simulation import static_user_simulator
|
||||
from google.adk.evaluation.simulation import user_simulator
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
class TestStaticUserSimulator:
|
||||
"""Test cases for StaticUserSimulator."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_user_message(self):
|
||||
"""Tests that the provided messages are returned in order followed by the stop signal."""
|
||||
conversation = [
|
||||
Invocation(
|
||||
invocation_id="inv1",
|
||||
user_content=types.Content(parts=[types.Part(text="message 1")]),
|
||||
),
|
||||
Invocation(
|
||||
invocation_id="inv2",
|
||||
user_content=types.Content(parts=[types.Part(text="message 2")]),
|
||||
),
|
||||
]
|
||||
simulator = static_user_simulator.StaticUserSimulator(
|
||||
static_conversation=conversation
|
||||
)
|
||||
|
||||
next_message_1 = await simulator.get_next_user_message(events=[])
|
||||
assert user_simulator.Status.SUCCESS == next_message_1.status
|
||||
assert "message 1" == next_message_1.user_message.parts[0].text
|
||||
|
||||
next_message_2 = await simulator.get_next_user_message(events=[])
|
||||
assert user_simulator.Status.SUCCESS == next_message_2.status
|
||||
assert "message 2" == next_message_2.user_message.parts[0].text
|
||||
|
||||
next_message_3 = await simulator.get_next_user_message(events=[])
|
||||
assert user_simulator.Status.STOP_SIGNAL_DETECTED == next_message_3.status
|
||||
assert next_message_3.user_message is None
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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
|
||||
|
||||
from google.adk.evaluation.simulation import user_simulator as user_simulator_module
|
||||
from google.adk.evaluation.simulation.user_simulator import BaseUserSimulatorConfig
|
||||
from google.adk.evaluation.simulation.user_simulator import NextUserMessage
|
||||
from google.adk.evaluation.simulation.user_simulator import register_user_simulator
|
||||
from google.adk.evaluation.simulation.user_simulator import Status
|
||||
from google.adk.evaluation.simulation.user_simulator import UserSimulator
|
||||
from google.genai.types import Content
|
||||
from pydantic import Field
|
||||
import pytest
|
||||
from typing_extensions import Literal
|
||||
|
||||
|
||||
def test_next_user_message_validation():
|
||||
"""Tests post-init validation of NextUserMessage."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"A user_message should be provided if and only if the status is"
|
||||
" SUCCESS"
|
||||
),
|
||||
):
|
||||
NextUserMessage(status=Status.SUCCESS)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"A user_message should be provided if and only if the status is"
|
||||
" SUCCESS"
|
||||
),
|
||||
):
|
||||
NextUserMessage(status=Status.TURN_LIMIT_REACHED, user_message=Content())
|
||||
|
||||
# these two should not cause exceptions
|
||||
NextUserMessage(status=Status.SUCCESS, user_message=Content())
|
||||
NextUserMessage(status=Status.TURN_LIMIT_REACHED)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# `register_user_simulator` -- the extension-point API in `user_simulator.py`.
|
||||
# End-to-end dispatch through `UserSimulatorProvider` is covered separately in
|
||||
# `test_user_simulator_provider.py`.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeConfig(BaseUserSimulatorConfig):
|
||||
"""Test-only config subclass with a unique Literal discriminator."""
|
||||
|
||||
type: Literal["fake_sim"] = Field(default="fake_sim")
|
||||
|
||||
|
||||
class _FakeSimulator(UserSimulator):
|
||||
"""Test-only simulator; internals do not matter, only its type identity."""
|
||||
|
||||
|
||||
def test_register_user_simulator_writes_to_shared_registry():
|
||||
"""`register_user_simulator(config_type, simulator_type)` must write the
|
||||
|
||||
mapping into `_SIMULATOR_BY_CONFIG_TYPE` so that any consumer -- including
|
||||
`UserSimulatorProvider` in another module -- can look it up.
|
||||
"""
|
||||
try:
|
||||
register_user_simulator(_FakeConfig, _FakeSimulator)
|
||||
assert (
|
||||
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(_FakeConfig)
|
||||
is _FakeSimulator
|
||||
)
|
||||
finally:
|
||||
# Clean up so we don't leak state into other tests.
|
||||
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.pop(_FakeConfig, None)
|
||||
|
||||
|
||||
def test_register_user_simulator_overwrites_existing_entry():
|
||||
"""Re-registering the same config type must overwrite the previous entry.
|
||||
|
||||
This lets a test or an out-of-tree extension swap in an alternative
|
||||
implementation for the same config type without having to unregister first.
|
||||
"""
|
||||
|
||||
class _AlternativeFakeSimulator(UserSimulator):
|
||||
pass
|
||||
|
||||
try:
|
||||
register_user_simulator(_FakeConfig, _FakeSimulator)
|
||||
register_user_simulator(_FakeConfig, _AlternativeFakeSimulator)
|
||||
assert (
|
||||
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(_FakeConfig)
|
||||
is _AlternativeFakeSimulator
|
||||
)
|
||||
finally:
|
||||
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.pop(_FakeConfig, None)
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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
|
||||
|
||||
from google.adk.errors.not_found_error import NotFoundError
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
|
||||
from google.adk.evaluation.simulation.user_simulator_personas import UserPersonaRegistry
|
||||
import pytest
|
||||
|
||||
|
||||
class TestUserBehavior:
|
||||
"""Test cases for UserBehavior."""
|
||||
|
||||
def test_create_user_behavior(self):
|
||||
"""Tests UserBehavior creation."""
|
||||
behavior = UserBehavior(
|
||||
name="test_behavior",
|
||||
description="Test behavior description.",
|
||||
behavior_instructions=["instruction1", "instruction2"],
|
||||
violation_rubrics=["violation1", "violation2"],
|
||||
)
|
||||
assert behavior.name == "test_behavior"
|
||||
assert behavior.description == "Test behavior description."
|
||||
assert behavior.behavior_instructions == ["instruction1", "instruction2"]
|
||||
assert behavior.violation_rubrics == ["violation1", "violation2"]
|
||||
|
||||
def test_get_behavior_instructions_str(self):
|
||||
"""Tests get_behavior_instructions_str method."""
|
||||
behavior = UserBehavior(
|
||||
name="test_behavior",
|
||||
description="Test behavior description.",
|
||||
behavior_instructions=["instruction1", "instruction2"],
|
||||
violation_rubrics=[],
|
||||
)
|
||||
assert (
|
||||
behavior.get_behavior_instructions_str()
|
||||
== " * instruction1\n * instruction2"
|
||||
)
|
||||
|
||||
def test_get_violation_rubrics_str(self):
|
||||
"""Tests get_violation_rubrics_str method."""
|
||||
behavior = UserBehavior(
|
||||
name="test_behavior",
|
||||
description="Test behavior description.",
|
||||
behavior_instructions=[],
|
||||
violation_rubrics=["violation1", "violation2"],
|
||||
)
|
||||
assert (
|
||||
behavior.get_violation_rubrics_str() == " * violation1\n * violation2"
|
||||
)
|
||||
|
||||
|
||||
class TestUserPersona:
|
||||
"""Test cases for UserPersona."""
|
||||
|
||||
def test_create_user_persona(self):
|
||||
"""Tests UserPersona creation."""
|
||||
behavior = UserBehavior(
|
||||
name="test_behavior",
|
||||
description="Test behavior description.",
|
||||
behavior_instructions=["instruction1"],
|
||||
violation_rubrics=["violation1"],
|
||||
)
|
||||
persona = UserPersona(
|
||||
id="test_persona",
|
||||
description="Test persona description.",
|
||||
behaviors=[behavior],
|
||||
)
|
||||
assert persona.id == "test_persona"
|
||||
assert persona.description == "Test persona description."
|
||||
assert persona.behaviors == [behavior]
|
||||
|
||||
|
||||
class TestUserPersonaRegistry:
|
||||
"""Test cases for UserPersonaRegistry."""
|
||||
|
||||
def test_register_and_get_persona(self):
|
||||
"""Tests register_persona and get_persona methods."""
|
||||
registry = UserPersonaRegistry()
|
||||
persona = UserPersona(
|
||||
id="test_persona", description="Test persona", behaviors=[]
|
||||
)
|
||||
registry.register_persona("persona1", persona)
|
||||
assert registry.get_persona("persona1") == persona
|
||||
|
||||
def test_get_persona_not_found(self):
|
||||
"""Tests get_persona for a non-existent persona."""
|
||||
registry = UserPersonaRegistry()
|
||||
with pytest.raises(NotFoundError, match="persona2 not found in registry."):
|
||||
registry.get_persona("persona2")
|
||||
|
||||
def test_update_persona(self):
|
||||
"""Tests updating an existing persona in the registry."""
|
||||
registry = UserPersonaRegistry()
|
||||
persona1 = UserPersona(
|
||||
id="test_persona1", description="Test persona 1", behaviors=[]
|
||||
)
|
||||
persona2 = UserPersona(
|
||||
id="test_persona2", description="Test persona 2", behaviors=[]
|
||||
)
|
||||
registry.register_persona("persona1", persona1)
|
||||
assert registry.get_persona("persona1") == persona1
|
||||
registry.register_persona("persona1", persona2)
|
||||
assert registry.get_persona("persona1") == persona2
|
||||
|
||||
def test_get_registered_personas(self):
|
||||
"""Tests get_registered_personas method."""
|
||||
registry = UserPersonaRegistry()
|
||||
persona1 = UserPersona(
|
||||
id="test_persona1", description="Test persona 1", behaviors=[]
|
||||
)
|
||||
persona2 = UserPersona(
|
||||
id="test_persona2", description="Test persona 2", behaviors=[]
|
||||
)
|
||||
registry.register_persona("persona1", persona1)
|
||||
registry.register_persona("persona2", persona2)
|
||||
registered_personas = registry.get_registered_personas()
|
||||
assert len(registered_personas) == 2
|
||||
assert persona1 in registered_personas
|
||||
assert persona2 in registered_personas
|
||||
@@ -0,0 +1,200 @@
|
||||
# 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
|
||||
|
||||
from google.adk.evaluation import conversation_scenarios
|
||||
from google.adk.evaluation import eval_case
|
||||
from google.adk.evaluation.simulation import user_simulator as user_simulator_module
|
||||
from google.adk.evaluation.simulation import user_simulator_provider
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulator
|
||||
from google.adk.evaluation.simulation.llm_backed_user_simulator import LlmBackedUserSimulatorConfig
|
||||
from google.adk.evaluation.simulation.static_user_simulator import StaticUserSimulator
|
||||
from google.adk.evaluation.simulation.user_simulator import BaseUserSimulatorConfig
|
||||
from google.genai import types
|
||||
from pydantic import Field
|
||||
import pytest
|
||||
from typing_extensions import Literal
|
||||
|
||||
_TEST_CONVERSATION = [
|
||||
eval_case.Invocation(
|
||||
invocation_id='inv1',
|
||||
user_content=types.Content(parts=[types.Part(text='Hello!')]),
|
||||
),
|
||||
]
|
||||
|
||||
_TEST_CONVERSATION_SCENARIO = conversation_scenarios.ConversationScenario(
|
||||
starting_prompt='Hello!', conversation_plan='test plan'
|
||||
)
|
||||
|
||||
|
||||
class TestUserSimulatorProvider:
|
||||
"""Test cases for the UserSimulatorProvider."""
|
||||
|
||||
def test_provide_static_user_simulator(self):
|
||||
"""Tests the case when a StaticUserSimulator should be provided."""
|
||||
provider = user_simulator_provider.UserSimulatorProvider()
|
||||
test_eval_case = eval_case.EvalCase(
|
||||
eval_id='test_eval_id',
|
||||
conversation=_TEST_CONVERSATION,
|
||||
)
|
||||
simulator = provider.provide(test_eval_case)
|
||||
assert isinstance(simulator, StaticUserSimulator)
|
||||
assert simulator.static_conversation == _TEST_CONVERSATION
|
||||
|
||||
def test_provide_llm_backed_user_simulator(self, mocker):
|
||||
"""Tests the case when a LlmBackedUserSimulator should be provided."""
|
||||
mock_llm_registry = mocker.patch(
|
||||
'google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry',
|
||||
autospec=True,
|
||||
)
|
||||
mock_llm_registry.return_value.resolve.return_value = mocker.Mock()
|
||||
# Test case 1: No config in provider.
|
||||
provider = user_simulator_provider.UserSimulatorProvider()
|
||||
test_eval_case = eval_case.EvalCase(
|
||||
eval_id='test_eval_id',
|
||||
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
|
||||
)
|
||||
simulator = provider.provide(test_eval_case)
|
||||
assert isinstance(simulator, LlmBackedUserSimulator)
|
||||
assert simulator._conversation_scenario == _TEST_CONVERSATION_SCENARIO
|
||||
|
||||
# Test case 2: Config in provider.
|
||||
llm_config = LlmBackedUserSimulatorConfig(
|
||||
model='test_model',
|
||||
)
|
||||
provider = user_simulator_provider.UserSimulatorProvider(
|
||||
user_simulator_config=llm_config
|
||||
)
|
||||
simulator = provider.provide(test_eval_case)
|
||||
assert isinstance(simulator, LlmBackedUserSimulator)
|
||||
assert simulator._conversation_scenario == _TEST_CONVERSATION_SCENARIO
|
||||
assert simulator._config.model == 'test_model'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compat + discriminator + registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_init_accepts_bare_base_config_but_provide_raises(self):
|
||||
"""A bare `BaseUserSimulatorConfig` is a valid *instance* of the base,
|
||||
|
||||
so `__init__` accepts it -- but the base has no registered simulator,
|
||||
so `provide()` should raise a clear error pointing the caller at the
|
||||
fix. Callers wanting the default should pass `None` instead.
|
||||
"""
|
||||
provider = user_simulator_provider.UserSimulatorProvider(
|
||||
user_simulator_config=BaseUserSimulatorConfig()
|
||||
)
|
||||
# __init__ stores the bare base as-is; no silent up-conversion.
|
||||
assert type(provider._user_simulator_config) is BaseUserSimulatorConfig
|
||||
|
||||
test_eval_case = eval_case.EvalCase(
|
||||
eval_id='test_eval_id',
|
||||
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r'No UserSimulator registered for config type'
|
||||
r' `BaseUserSimulatorConfig`'
|
||||
),
|
||||
):
|
||||
provider.provide(test_eval_case)
|
||||
|
||||
def test_init_rejects_non_config_argument(self):
|
||||
"""Passing something that isn't a `BaseUserSimulatorConfig` should raise
|
||||
|
||||
a clear ValueError.
|
||||
"""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=r'Expect config of type `.*BaseUserSimulatorConfig.*`\.',
|
||||
):
|
||||
user_simulator_provider.UserSimulatorProvider(
|
||||
user_simulator_config='not a config' # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# NOTE: The "both / neither of conversation, conversation_scenario"
|
||||
# checks in `provide()` are defensive; `EvalCase` itself enforces the same
|
||||
# invariant at construction time via a model_validator, so those branches
|
||||
# in the provider are effectively unreachable and don't warrant a unit
|
||||
# test at this layer.
|
||||
|
||||
def test_base_config_type_defaults_to_none(self):
|
||||
"""The base `BaseUserSimulatorConfig.type` must default to `None` -- the
|
||||
|
||||
base class must not hard-code a specific subclass's discriminator value.
|
||||
Concrete subclasses supply their own `Literal[...]` default.
|
||||
"""
|
||||
base = BaseUserSimulatorConfig()
|
||||
assert base.type is None
|
||||
|
||||
def test_llm_backed_config_has_locked_type_literal(self):
|
||||
"""The `type` discriminator on `LlmBackedUserSimulatorConfig` must be a
|
||||
|
||||
Literal locked to `"llm_backed"`, so future subclasses can dispatch
|
||||
correctly via pydantic's discriminated union.
|
||||
"""
|
||||
config = LlmBackedUserSimulatorConfig()
|
||||
assert config.type == 'llm_backed'
|
||||
# Attempting to construct with a different `type` value must fail
|
||||
# validation (Literal constraint).
|
||||
with pytest.raises(Exception):
|
||||
LlmBackedUserSimulatorConfig(type='something_else')
|
||||
|
||||
def test_llm_backed_user_simulator_registered_by_provider_module(self):
|
||||
"""Importing `user_simulator_provider` must wire the built-in
|
||||
|
||||
`LlmBackedUserSimulator` into the shared registry. This is the "batteries
|
||||
included" contract callers rely on: they can `UserSimulatorProvider()`
|
||||
without ever touching `register_user_simulator(...)`. If the registration
|
||||
line at the top of the provider module is removed, this test catches it
|
||||
immediately -- otherwise dispatch would silently fall through to the
|
||||
"unregistered config type" error path.
|
||||
"""
|
||||
assert (
|
||||
user_simulator_module._SIMULATOR_BY_CONFIG_TYPE.get(
|
||||
LlmBackedUserSimulatorConfig
|
||||
)
|
||||
is LlmBackedUserSimulator
|
||||
)
|
||||
|
||||
def test_provide_raises_for_unregistered_config_type(self, mocker):
|
||||
"""If the caller supplies a config subclass that no one has registered,
|
||||
|
||||
provide() must raise a clear error naming the offending type.
|
||||
"""
|
||||
mocker.patch(
|
||||
'google.adk.evaluation.simulation.llm_backed_user_simulator.LLMRegistry',
|
||||
autospec=True,
|
||||
)
|
||||
|
||||
class _UnregisteredConfig(BaseUserSimulatorConfig):
|
||||
type: Literal['unregistered'] = Field(default='unregistered')
|
||||
|
||||
provider = user_simulator_provider.UserSimulatorProvider(
|
||||
user_simulator_config=_UnregisteredConfig()
|
||||
)
|
||||
test_eval_case = eval_case.EvalCase(
|
||||
eval_id='test_eval_id',
|
||||
conversation_scenario=_TEST_CONVERSATION_SCENARIO,
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
r'No UserSimulator registered for config type'
|
||||
r' `_UnregisteredConfig`'
|
||||
),
|
||||
):
|
||||
provider.provide(test_eval_case)
|
||||
Reference in New Issue
Block a user