1020 lines
34 KiB
Python
1020 lines
34 KiB
Python
import re
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
import mlflow
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.genai.datasets.evaluation_dataset import EvaluationDataset
|
|
from mlflow.genai.simulators import (
|
|
BaseSimulatedUserAgent,
|
|
ConversationSimulator,
|
|
SimulatedUserAgent,
|
|
SimulatorContext,
|
|
)
|
|
from mlflow.genai.simulators.prompts import DEFAULT_PERSONA
|
|
from mlflow.genai.simulators.simulator import _MAX_METADATA_LENGTH
|
|
from mlflow.tracing.constant import TraceMetadataKey
|
|
|
|
|
|
def create_mock_evaluation_dataset(inputs: list[dict[str, object]]) -> Mock:
|
|
mock_dataset = Mock(spec=EvaluationDataset)
|
|
mock_dataset.to_df.return_value = pd.DataFrame({"inputs": inputs})
|
|
return mock_dataset
|
|
|
|
|
|
def test_simulated_user_agent_generate_initial_message():
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.return_value = "Hello, I have a question about ML."
|
|
|
|
agent = SimulatedUserAgent()
|
|
context = SimulatorContext(
|
|
goal="Learn about MLflow",
|
|
persona="You are a beginner who asks curious questions.",
|
|
conversation_history=[],
|
|
turn=0,
|
|
)
|
|
|
|
message = agent.generate_message(context)
|
|
|
|
assert message == "Hello, I have a question about ML."
|
|
mock_invoke.assert_called_once()
|
|
|
|
call_args = mock_invoke.call_args
|
|
messages = call_args.kwargs["messages"]
|
|
prompt = messages[0].content
|
|
|
|
assert "Learn about MLflow" in prompt
|
|
assert "beginner" in prompt
|
|
|
|
|
|
def test_simulated_user_agent_generate_followup_message():
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.return_value = "Can you tell me more?"
|
|
|
|
agent = SimulatedUserAgent()
|
|
context = SimulatorContext(
|
|
goal="Learn about MLflow",
|
|
persona="A helpful user",
|
|
conversation_history=[
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
],
|
|
turn=1,
|
|
)
|
|
|
|
message = agent.generate_message(context)
|
|
|
|
assert message == "Can you tell me more?"
|
|
mock_invoke.assert_called_once()
|
|
|
|
call_args = mock_invoke.call_args
|
|
messages = call_args.kwargs["messages"]
|
|
prompt = messages[0].content
|
|
|
|
assert "Hi there!" in prompt
|
|
|
|
|
|
def test_simulated_user_agent_default_persona():
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.return_value = "Test message"
|
|
|
|
agent = SimulatedUserAgent()
|
|
context = SimulatorContext(
|
|
goal="Learn about ML",
|
|
persona=DEFAULT_PERSONA,
|
|
conversation_history=[],
|
|
turn=0,
|
|
)
|
|
|
|
message = agent.generate_message(context)
|
|
|
|
assert message == "Test message"
|
|
|
|
call_args = mock_invoke.call_args
|
|
messages = call_args.kwargs["messages"]
|
|
prompt = messages[0].content
|
|
|
|
assert "inquisitive user" in prompt.lower()
|
|
|
|
|
|
def test_conversation_simulator_basic_simulation(
|
|
simple_test_case, mock_predict_fn, simulation_mocks
|
|
):
|
|
# Each turn: generate_message + _check_goal_achieved
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"What is MLflow?", # turn 0 generate_message
|
|
'{"rationale": "Goal not achieved yet", "result": "no"}', # turn 0 goal check
|
|
"Can you explain more?", # turn 1 generate_message
|
|
'{"rationale": "Goal not achieved yet", "result": "no"}', # turn 1 goal check
|
|
]
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=2,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn)
|
|
|
|
assert len(all_traces) == 1 # 1 test case
|
|
assert len(all_traces[0]) == 2 # 2 traces
|
|
assert all(t is simulation_mocks["trace"] for t in all_traces[0])
|
|
assert simulation_mocks["invoke"].call_count == 4 # 2 turns * 2 calls each
|
|
assert len(simulation_mocks["context_calls"]) == 2 # one per turn
|
|
|
|
|
|
def test_conversation_simulator_max_turns_stopping(
|
|
simple_test_case, mock_predict_fn, simulation_mocks
|
|
):
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message", # turn 0 generate_message
|
|
'{"rationale": "Not yet", "result": "no"}', # turn 0 goal check
|
|
"Test message", # turn 1 generate_message
|
|
'{"rationale": "Not yet", "result": "no"}', # turn 1 goal check
|
|
"Test message", # turn 2 generate_message
|
|
'{"rationale": "Not yet", "result": "no"}', # turn 2 goal check
|
|
]
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=3,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn)
|
|
|
|
assert len(all_traces) == 1 # 1 test case
|
|
assert len(all_traces[0]) == 3 # 3 traces
|
|
assert simulation_mocks["invoke"].call_count == 6 # 3 turns * 2 calls each
|
|
|
|
|
|
def test_conversation_simulator_empty_response_stopping(simple_test_case, simulation_mocks):
|
|
simulation_mocks["invoke"].return_value = "Test message"
|
|
|
|
def empty_predict_fn(input=None, **kwargs):
|
|
return {
|
|
"output": [
|
|
{
|
|
"id": "msg_123",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": ""}],
|
|
}
|
|
]
|
|
}
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=5,
|
|
)
|
|
|
|
all_traces = simulator.simulate(empty_predict_fn)
|
|
|
|
assert len(all_traces) == 1
|
|
assert len(all_traces[0]) == 1 # Only 1 trace before stopping
|
|
# Only generate_message called, goal check not called due to empty response
|
|
assert simulation_mocks["invoke"].call_count == 1
|
|
|
|
|
|
def test_conversation_simulator_goal_achieved_stopping(
|
|
simple_test_case, mock_predict_fn, simulation_mocks
|
|
):
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message", # turn 0 generate_message
|
|
'{"rationale": "Goal achieved!", "result": "yes"}', # turn 0 goal check -> stop
|
|
]
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=5,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn)
|
|
|
|
assert len(all_traces) == 1
|
|
# Only 1 trace before goal was achieved
|
|
assert len(all_traces[0]) == 1
|
|
# 2 calls: generate_message + goal check
|
|
assert simulation_mocks["invoke"].call_count == 2
|
|
# Verify goal check was the second call with goal check prompt
|
|
goal_check_call = simulation_mocks["invoke"].call_args_list[1]
|
|
goal_check_prompt = goal_check_call.kwargs["messages"][0].content
|
|
assert "achieved" in goal_check_prompt.lower()
|
|
|
|
|
|
def test_conversation_simulator_context_passing(test_case_with_context, simulation_mocks):
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Not achieved", "result": "no"}',
|
|
]
|
|
|
|
captured_kwargs = {}
|
|
|
|
def capturing_predict_fn(input=None, **kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
return {
|
|
"output": [
|
|
{
|
|
"id": "msg_123",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Response"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[test_case_with_context],
|
|
max_turns=1,
|
|
)
|
|
|
|
all_traces = simulator.simulate(capturing_predict_fn)
|
|
|
|
assert len(all_traces) == 1
|
|
assert len(all_traces[0]) == 1
|
|
# Verify context was passed to predict_fn
|
|
assert captured_kwargs.get("user_id") == "U001"
|
|
assert captured_kwargs.get("session_id") == "S001"
|
|
|
|
|
|
def test_conversation_simulator_mlflow_session_id_passed_to_predict_fn(
|
|
simple_test_case, simulation_mocks
|
|
):
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Not yet", "result": "no"}',
|
|
"Test message 2",
|
|
'{"rationale": "Not yet", "result": "no"}',
|
|
]
|
|
|
|
captured_session_ids = []
|
|
|
|
def capturing_predict_fn(input=None, **kwargs):
|
|
captured_session_ids.append(kwargs.get("mlflow_session_id"))
|
|
return {
|
|
"output": [
|
|
{
|
|
"id": "msg_123",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Response"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=2,
|
|
)
|
|
|
|
all_traces = simulator.simulate(capturing_predict_fn)
|
|
|
|
assert len(all_traces) == 1
|
|
assert len(all_traces[0]) == 2
|
|
# Verify mlflow_session_id was passed to predict_fn
|
|
assert len(captured_session_ids) == 2
|
|
assert all(sid is not None for sid in captured_session_ids)
|
|
assert all(sid.startswith("sim-") for sid in captured_session_ids)
|
|
# Verify session ID is consistent across all turns in the same conversation
|
|
assert captured_session_ids[0] == captured_session_ids[1]
|
|
|
|
|
|
def test_conversation_simulator_multiple_test_cases(
|
|
simple_test_case, test_case_with_persona, mock_predict_fn, simulation_mocks
|
|
):
|
|
# 2 test cases * 2 turns each * 2 calls per turn = 8 calls
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Not yet", "result": "no"}',
|
|
"Test message",
|
|
'{"rationale": "Not yet", "result": "no"}',
|
|
"Test message",
|
|
'{"rationale": "Not yet", "result": "no"}',
|
|
"Test message",
|
|
'{"rationale": "Not yet", "result": "no"}',
|
|
]
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case, test_case_with_persona],
|
|
max_turns=2,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn)
|
|
|
|
assert len(all_traces) == 2 # 2 test cases
|
|
assert len(all_traces[0]) == 2 # 2 traces for first test case
|
|
assert len(all_traces[1]) == 2 # 2 traces for second test case
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("test_cases", "expected_error"),
|
|
[
|
|
([], "test_cases cannot be empty"),
|
|
([{"persona": "test"}], r"indices \[0\].*'goal' field"),
|
|
(
|
|
[{"goal": "valid"}, {"persona": "missing goal"}],
|
|
r"indices \[1\].*'goal' field",
|
|
),
|
|
(
|
|
[{"persona": "a"}, {"goal": "valid"}, {"persona": "b"}],
|
|
r"indices \[0, 2\].*'goal' field",
|
|
),
|
|
],
|
|
ids=[
|
|
"empty_test_cases",
|
|
"missing_goal",
|
|
"second_case_missing_goal",
|
|
"multiple_missing_goals",
|
|
],
|
|
)
|
|
def test_conversation_simulator_validation(test_cases, expected_error):
|
|
with pytest.raises(ValueError, match=expected_error):
|
|
ConversationSimulator(
|
|
test_cases=test_cases,
|
|
max_turns=2,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("test_cases", "expected_error"),
|
|
[
|
|
(
|
|
[{"goal": "test", "context": {"input": "foo"}}],
|
|
r"indices \[0\].*reserved",
|
|
),
|
|
(
|
|
[{"goal": "test", "context": {"messages": []}}],
|
|
r"indices \[0\].*reserved",
|
|
),
|
|
(
|
|
[{"goal": "test", "context": {"mlflow_session_id": "abc"}}],
|
|
r"indices \[0\].*reserved",
|
|
),
|
|
(
|
|
[{"goal": "ok"}, {"goal": "test", "context": {"input": "bar"}}],
|
|
r"indices \[1\].*reserved",
|
|
),
|
|
],
|
|
ids=[
|
|
"context_has_input",
|
|
"context_has_messages",
|
|
"context_has_mlflow_session_id",
|
|
"second_case_has_reserved_key",
|
|
],
|
|
)
|
|
def test_conversation_simulator_rejects_reserved_context_keys(test_cases, expected_error):
|
|
with pytest.raises(ValueError, match=expected_error):
|
|
ConversationSimulator(
|
|
test_cases=test_cases,
|
|
max_turns=2,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("test_cases", "expected_error"),
|
|
[
|
|
([{"goal": "test", "context": "foo"}], r"indices \[0\].*'context' as a dict"),
|
|
([{"goal": "test", "context": ["foo"]}], r"indices \[0\].*'context' as a dict"),
|
|
([{"goal": "ok"}, {"goal": "test", "context": 1}], r"indices \[1\].*'context' as a dict"),
|
|
],
|
|
ids=["context_string", "context_list", "second_case_context_int"],
|
|
)
|
|
def test_conversation_simulator_rejects_invalid_context_types(test_cases, expected_error):
|
|
with pytest.raises(ValueError, match=expected_error):
|
|
ConversationSimulator(
|
|
test_cases=test_cases,
|
|
max_turns=2,
|
|
)
|
|
|
|
|
|
def test_conversation_simulator_accepts_dataframe_with_missing_context_values():
|
|
test_cases_df = pd.DataFrame([
|
|
{"goal": "Debug an error", "context": {"user_id": "U001"}},
|
|
{"goal": "Learn about MLflow"},
|
|
])
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=test_cases_df,
|
|
max_turns=2,
|
|
)
|
|
|
|
assert len(simulator.test_cases) == 2
|
|
assert simulator.test_cases[0]["context"] == {"user_id": "U001"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"inputs",
|
|
[
|
|
[{"goal": "Learn about MLflow"}],
|
|
[{"goal": "Debug issue", "persona": "Engineer"}],
|
|
[{"goal": "Ask questions", "persona": "Student", "context": {"id": "1"}}],
|
|
[{"goal": "Learn ML", "simulation_guidelines": "Be concise"}],
|
|
[{"goal": "Learn ML", "simulation_guidelines": ["Be concise", "Ask follow-ups"]}],
|
|
[
|
|
{
|
|
"goal": "Debug deployment",
|
|
"persona": "Engineer",
|
|
"context": {"env": "prod"},
|
|
"simulation_guidelines": "Focus on logs",
|
|
}
|
|
],
|
|
],
|
|
)
|
|
def test_conversation_simulator_evaluation_dataset_valid(inputs):
|
|
mock_dataset = create_mock_evaluation_dataset(inputs)
|
|
simulator = ConversationSimulator(test_cases=mock_dataset, max_turns=2)
|
|
assert len(simulator.test_cases) == len(inputs)
|
|
assert simulator.test_cases == inputs
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"inputs",
|
|
[
|
|
[{"request": "What is MLflow?"}],
|
|
[{"inputs": {"query": "Help me"}, "expected_response": "Sure!"}],
|
|
[{"inputs": {"question": "How to log?", "answer": "Use mlflow.log"}}],
|
|
[],
|
|
],
|
|
)
|
|
def test_conversation_simulator_evaluation_dataset_invalid(inputs):
|
|
mock_dataset = create_mock_evaluation_dataset(inputs)
|
|
with pytest.raises(ValueError, match="conversational test cases with a 'goal' field"):
|
|
ConversationSimulator(test_cases=mock_dataset, max_turns=2)
|
|
|
|
|
|
def test_reassignment_with_valid_test_cases(simple_test_case):
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=2)
|
|
new_test_cases = [
|
|
{"goal": "New goal"},
|
|
]
|
|
simulator.test_cases = new_test_cases
|
|
assert simulator.test_cases == new_test_cases
|
|
assert len(simulator.test_cases) == 1
|
|
|
|
|
|
def test_reassignment_with_dataframe(simple_test_case):
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=2)
|
|
df = pd.DataFrame([{"goal": "Goal from DataFrame", "persona": "Analyst"}])
|
|
simulator.test_cases = df
|
|
assert simulator.test_cases == [{"goal": "Goal from DataFrame", "persona": "Analyst"}]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("invalid_test_cases", "expected_error"),
|
|
[
|
|
([], "test_cases cannot be empty"),
|
|
([{"persona": "no goal here"}], r"indices \[0\].*'goal' field"),
|
|
],
|
|
)
|
|
def test_reassignment_with_invalid_test_cases_raises_error(
|
|
simple_test_case, invalid_test_cases, expected_error
|
|
):
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=2)
|
|
original_test_cases = simulator.test_cases
|
|
with pytest.raises(ValueError, match=expected_error):
|
|
simulator.test_cases = invalid_test_cases
|
|
assert simulator.test_cases == original_test_cases
|
|
|
|
|
|
def test_simulator_context_is_first_turn():
|
|
context_first = SimulatorContext(
|
|
goal="Test goal",
|
|
persona="Test persona",
|
|
conversation_history=[],
|
|
turn=0,
|
|
)
|
|
assert context_first.is_first_turn is True
|
|
|
|
context_later = SimulatorContext(
|
|
goal="Test goal",
|
|
persona="Test persona",
|
|
conversation_history=[{"role": "user", "content": "Hello"}],
|
|
turn=1,
|
|
)
|
|
assert context_later.is_first_turn is False
|
|
|
|
|
|
def test_simulator_context_formatted_history():
|
|
context_empty = SimulatorContext(
|
|
goal="Test goal",
|
|
persona="Test persona",
|
|
conversation_history=[],
|
|
turn=0,
|
|
)
|
|
assert context_empty.formatted_history is None
|
|
|
|
context_with_history = SimulatorContext(
|
|
goal="Test goal",
|
|
persona="Test persona",
|
|
conversation_history=[
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
],
|
|
turn=1,
|
|
)
|
|
assert context_with_history.formatted_history == "user: Hello\nassistant: Hi there!"
|
|
|
|
|
|
def test_simulator_context_last_assistant_response():
|
|
context_empty = SimulatorContext(
|
|
goal="Test goal",
|
|
persona="Test persona",
|
|
conversation_history=[],
|
|
turn=0,
|
|
)
|
|
assert context_empty.last_assistant_response is None
|
|
|
|
context_with_history = SimulatorContext(
|
|
goal="Test goal",
|
|
persona="Test persona",
|
|
conversation_history=[
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
],
|
|
turn=1,
|
|
)
|
|
assert context_with_history.last_assistant_response == "Hi there!"
|
|
|
|
|
|
def test_simulator_context_is_frozen():
|
|
context = SimulatorContext(
|
|
goal="Test goal",
|
|
persona="Test persona",
|
|
conversation_history=[],
|
|
turn=0,
|
|
)
|
|
with pytest.raises(AttributeError, match="cannot assign to field"):
|
|
context.goal = "New goal"
|
|
|
|
|
|
def test_simulator_context_with_simulation_guidelines():
|
|
context = SimulatorContext(
|
|
goal="Test goal",
|
|
persona="Test persona",
|
|
conversation_history=[],
|
|
turn=0,
|
|
simulation_guidelines="Be concise and ask clarifying questions",
|
|
)
|
|
assert context.simulation_guidelines == "Be concise and ask clarifying questions"
|
|
|
|
|
|
def test_custom_user_agent_class(simple_test_case, mock_predict_fn, simulation_mocks):
|
|
class CustomUserAgent(BaseSimulatedUserAgent):
|
|
def generate_message(self, context: SimulatorContext) -> str:
|
|
return f"Custom message for: {context.goal}"
|
|
|
|
simulation_mocks["invoke"].return_value = '{"rationale": "Goal achieved!", "result": "yes"}'
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=2,
|
|
user_agent_class=CustomUserAgent,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn)
|
|
|
|
assert len(all_traces) == 1
|
|
assert len(all_traces[0]) == 1
|
|
|
|
|
|
def test_user_agent_class_default(simple_test_case):
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=2,
|
|
)
|
|
assert simulator.user_agent_class is SimulatedUserAgent
|
|
|
|
|
|
def test_user_agent_class_receives_context(simple_test_case, mock_predict_fn, simulation_mocks):
|
|
captured_contexts = []
|
|
|
|
class ContextCapturingAgent(BaseSimulatedUserAgent):
|
|
def generate_message(self, context: SimulatorContext) -> str:
|
|
captured_contexts.append(context)
|
|
return f"Message for turn {context.turn}"
|
|
|
|
simulation_mocks["invoke"].return_value = '{"rationale": "Not yet", "result": "no"}'
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=2,
|
|
user_agent_class=ContextCapturingAgent,
|
|
)
|
|
|
|
simulator.simulate(mock_predict_fn)
|
|
|
|
assert len(captured_contexts) == 2
|
|
assert captured_contexts[0].turn == 0
|
|
assert captured_contexts[0].is_first_turn is True
|
|
assert captured_contexts[0].goal == simple_test_case["goal"]
|
|
assert captured_contexts[1].turn == 1
|
|
assert captured_contexts[1].is_first_turn is False
|
|
|
|
|
|
def test_conversation_simulator_sets_simulation_metadata(mock_predict_fn_with_context):
|
|
long_goal = "A" * 500
|
|
long_persona = "B" * 500
|
|
context = {"user_id": "U001", "session_id": "S001"}
|
|
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Not achieved", "result": "no"}',
|
|
"Follow up message",
|
|
'{"rationale": "Goal achieved!", "result": "yes"}',
|
|
]
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[{"goal": long_goal, "persona": long_persona, "context": context}],
|
|
max_turns=2,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn_with_context)
|
|
first_test_case_traces = all_traces[0]
|
|
|
|
assert len(first_test_case_traces) == 2
|
|
|
|
for trace in first_test_case_traces:
|
|
metadata = trace.info.trace_metadata
|
|
|
|
assert TraceMetadataKey.TRACE_SESSION in metadata
|
|
assert metadata["mlflow.simulation.goal"] == long_goal[:_MAX_METADATA_LENGTH]
|
|
assert metadata["mlflow.simulation.persona"] == long_persona[:_MAX_METADATA_LENGTH]
|
|
|
|
|
|
def test_conversation_simulator_uses_default_persona(mock_predict_fn):
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Goal achieved!", "result": "yes"}',
|
|
]
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[{"goal": "Test goal"}],
|
|
max_turns=1,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn)
|
|
|
|
trace = all_traces[0][0]
|
|
metadata = trace.info.trace_metadata
|
|
|
|
assert metadata["mlflow.simulation.goal"] == "Test goal"
|
|
assert metadata["mlflow.simulation.persona"] == DEFAULT_PERSONA
|
|
assert TraceMetadataKey.TRACE_SESSION in metadata
|
|
|
|
|
|
def test_conversation_simulator_logs_expectations_to_first_trace(mock_predict_fn):
|
|
expectations = {"expected_topic": "MLflow", "expected_sentiment": "positive"}
|
|
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Not achieved", "result": "no"}',
|
|
"Follow up message",
|
|
'{"rationale": "Goal achieved!", "result": "yes"}',
|
|
]
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[{"goal": "Test goal", "expectations": expectations}],
|
|
max_turns=2,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn)
|
|
|
|
assert len(all_traces[0]) == 2
|
|
|
|
first_trace = all_traces[0][0]
|
|
expectation_assessments = [
|
|
a for a in first_trace.info.assessments if a.expectation is not None
|
|
]
|
|
|
|
assert len(expectation_assessments) == 2
|
|
for assessment in expectation_assessments:
|
|
assert assessment.name in expectations
|
|
assert assessment.expectation.value == expectations[assessment.name]
|
|
assert TraceMetadataKey.TRACE_SESSION in assessment.metadata
|
|
|
|
second_trace = all_traces[0][1]
|
|
second_trace_assessments = second_trace.info.assessments
|
|
second_expectation_assessments = [
|
|
a for a in second_trace_assessments if a.expectation is not None
|
|
]
|
|
assert len(second_expectation_assessments) == 0
|
|
|
|
|
|
def test_invoke_llm_with_prompt_only():
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.return_value = "LLM response"
|
|
|
|
agent = SimulatedUserAgent()
|
|
result = agent.invoke_llm("Test prompt")
|
|
|
|
assert result == "LLM response"
|
|
mock_invoke.assert_called_once()
|
|
call_args = mock_invoke.call_args
|
|
messages = call_args.kwargs["messages"]
|
|
assert len(messages) == 1
|
|
assert messages[0].role == "user"
|
|
assert messages[0].content == "Test prompt"
|
|
|
|
|
|
def test_invoke_llm_with_system_prompt():
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.return_value = "LLM response with system"
|
|
|
|
agent = SimulatedUserAgent()
|
|
result = agent.invoke_llm("Test prompt", system_prompt="System instructions")
|
|
|
|
assert result == "LLM response with system"
|
|
mock_invoke.assert_called_once()
|
|
call_args = mock_invoke.call_args
|
|
messages = call_args.kwargs["messages"]
|
|
assert len(messages) == 2
|
|
assert messages[0].role == "system"
|
|
assert messages[0].content == "System instructions"
|
|
assert messages[1].role == "user"
|
|
assert messages[1].content == "Test prompt"
|
|
|
|
|
|
def test_invalid_user_agent_class_raises_type_error(simple_test_case):
|
|
class NotAUserAgent:
|
|
pass
|
|
|
|
with pytest.raises(TypeError, match="must be a subclass of BaseSimulatedUserAgent"):
|
|
ConversationSimulator(
|
|
test_cases=[simple_test_case],
|
|
max_turns=2,
|
|
user_agent_class=NotAUserAgent,
|
|
)
|
|
|
|
|
|
def test_conversation_simulator_digest_is_deterministic():
|
|
test_cases = [
|
|
{"goal": "Learn about MLflow"},
|
|
{"goal": "Debug deployment", "persona": "Data scientist"},
|
|
{"goal": "Setup", "context": {"env": "prod"}},
|
|
]
|
|
simulator1 = ConversationSimulator(test_cases=test_cases, max_turns=2)
|
|
simulator2 = ConversationSimulator(test_cases=test_cases, max_turns=2)
|
|
|
|
digest1 = simulator1._compute_test_case_digest()
|
|
digest2 = simulator2._compute_test_case_digest()
|
|
|
|
assert digest1 == digest2
|
|
assert isinstance(digest1, str)
|
|
assert len(digest1) == 8
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("test_cases_1", "test_cases_2"),
|
|
[
|
|
# Different goals
|
|
([{"goal": "Goal A"}], [{"goal": "Goal B"}]),
|
|
# Adding persona changes digest
|
|
([{"goal": "Goal"}], [{"goal": "Goal", "persona": "Engineer"}]),
|
|
# Different order
|
|
([{"goal": "A"}, {"goal": "B"}], [{"goal": "B"}, {"goal": "A"}]),
|
|
],
|
|
ids=["different_goals", "added_persona", "different_order"],
|
|
)
|
|
def test_conversation_simulator_digest_differs_for_different_test_cases(test_cases_1, test_cases_2):
|
|
simulator1 = ConversationSimulator(test_cases=test_cases_1, max_turns=2)
|
|
simulator2 = ConversationSimulator(test_cases=test_cases_2, max_turns=2)
|
|
|
|
assert simulator1._compute_test_case_digest() != simulator2._compute_test_case_digest()
|
|
|
|
|
|
def test_conversation_simulator_get_dataset_name_default():
|
|
test_cases = [{"goal": "Learn about MLflow"}]
|
|
simulator = ConversationSimulator(test_cases=test_cases, max_turns=2)
|
|
|
|
assert simulator._get_dataset_name() == "conversational_dataset"
|
|
|
|
|
|
def test_conversation_simulator_get_dataset_name_from_evaluation_dataset():
|
|
inputs = [{"goal": "Learn about MLflow"}]
|
|
mock_dataset = create_mock_evaluation_dataset(inputs)
|
|
mock_dataset.name = "my_custom_dataset"
|
|
|
|
simulator = ConversationSimulator(test_cases=mock_dataset, max_turns=2)
|
|
|
|
assert simulator._get_dataset_name() == "my_custom_dataset"
|
|
|
|
|
|
def test_simulate_creates_run_when_no_parent_run(
|
|
tmp_path, simple_test_case, mock_predict_fn, simulation_mocks
|
|
):
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Goal achieved!", "result": "yes"}',
|
|
]
|
|
|
|
mlflow.set_tracking_uri(f"sqlite:///{tmp_path}/mlflow.db")
|
|
mlflow.set_experiment("test-experiment")
|
|
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=1)
|
|
simulator.simulate(mock_predict_fn)
|
|
|
|
runs = mlflow.search_runs()
|
|
assert len(runs) == 1
|
|
run_name = runs.iloc[0]["tags.mlflow.runName"]
|
|
assert re.match(r"^simulation-[0-9a-f]{8}$", run_name)
|
|
|
|
|
|
def test_simulate_uses_parent_run_when_exists(
|
|
tmp_path, simple_test_case, mock_predict_fn, simulation_mocks
|
|
):
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Goal achieved!", "result": "yes"}',
|
|
]
|
|
|
|
mlflow.set_tracking_uri(f"sqlite:///{tmp_path}/mlflow.db")
|
|
mlflow.set_experiment("test-experiment")
|
|
|
|
with mlflow.start_run(run_name="parent-run") as parent_run:
|
|
parent_run_id = parent_run.info.run_id
|
|
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=1)
|
|
simulator.simulate(mock_predict_fn)
|
|
|
|
assert mlflow.active_run().info.run_id == parent_run_id
|
|
|
|
runs = mlflow.search_runs()
|
|
assert len(runs) == 1
|
|
assert runs.iloc[0]["tags.mlflow.runName"] == "parent-run"
|
|
|
|
|
|
def test_simulate_run_name_format(tmp_path, simple_test_case, mock_predict_fn, simulation_mocks):
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Goal achieved!", "result": "yes"}',
|
|
]
|
|
|
|
mlflow.set_tracking_uri(f"sqlite:///{tmp_path}/mlflow.db")
|
|
mlflow.set_experiment("test-experiment")
|
|
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=1)
|
|
simulator.simulate(mock_predict_fn)
|
|
|
|
runs = mlflow.search_runs()
|
|
run_name = runs.iloc[0]["tags.mlflow.runName"]
|
|
|
|
assert run_name.startswith("simulation-")
|
|
hex_part = run_name[len("simulation-") :]
|
|
assert len(hex_part) == 8
|
|
assert re.match(r"^[0-9a-f]+$", hex_part)
|
|
|
|
|
|
def test_conversation_simulator_completions_messages_format(simple_test_case, simulation_mocks):
|
|
simulation_mocks["invoke"].side_effect = [
|
|
"Test message",
|
|
'{"rationale": "Goal achieved!", "result": "yes"}',
|
|
]
|
|
|
|
captured_messages_snapshots = []
|
|
|
|
def capturing_predict_fn(messages: list[dict[str, str]] | None = None, **kwargs):
|
|
captured_messages_snapshots.append(list(messages) if messages else None)
|
|
return {
|
|
"output": [
|
|
{
|
|
"id": "msg_123",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Response"}],
|
|
}
|
|
]
|
|
}
|
|
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=1)
|
|
simulator.simulate(capturing_predict_fn)
|
|
|
|
assert len(captured_messages_snapshots) == 1
|
|
assert captured_messages_snapshots[0][0]["role"] == "user"
|
|
assert captured_messages_snapshots[0][0]["content"] == "Test message"
|
|
|
|
|
|
def test_conversation_simulator_rejects_both_input_and_messages(simple_test_case, simulation_mocks):
|
|
simulation_mocks["invoke"].return_value = "Test message"
|
|
|
|
def invalid_predict_fn(input: list[dict[str, str]], messages: list[dict[str, str]], **kwargs):
|
|
return {
|
|
"output": [
|
|
{"role": "assistant", "content": [{"type": "output_text", "text": "Response"}]}
|
|
]
|
|
}
|
|
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=1)
|
|
|
|
with pytest.raises(MlflowException, match="cannot have both 'messages' and 'input' parameters"):
|
|
simulator.simulate(invalid_predict_fn)
|
|
|
|
|
|
def test_conversation_simulator_rejects_neither_input_nor_messages(
|
|
simple_test_case, simulation_mocks
|
|
):
|
|
simulation_mocks["invoke"].return_value = "Test message"
|
|
|
|
def invalid_predict_fn(**kwargs):
|
|
return None
|
|
|
|
simulator = ConversationSimulator(test_cases=[simple_test_case], max_turns=1)
|
|
|
|
with pytest.raises(MlflowException, match="must accept either 'messages' or 'input'"):
|
|
simulator.simulate(invalid_predict_fn)
|
|
|
|
|
|
def test_simulated_user_agent_with_simulation_guidelines():
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.return_value = "I have a question about ML pipelines."
|
|
|
|
agent = SimulatedUserAgent()
|
|
context = SimulatorContext(
|
|
goal="Learn about ML pipelines",
|
|
persona=DEFAULT_PERSONA,
|
|
conversation_history=[],
|
|
turn=0,
|
|
simulation_guidelines="Ask clarifying questions before proceeding",
|
|
)
|
|
|
|
message = agent.generate_message(context)
|
|
|
|
assert message == "I have a question about ML pipelines."
|
|
mock_invoke.assert_called_once()
|
|
|
|
call_args = mock_invoke.call_args
|
|
messages = call_args.kwargs["messages"]
|
|
prompt = messages[0].content
|
|
|
|
assert "Learn about ML pipelines" in prompt
|
|
assert "Ask clarifying questions before proceeding" in prompt
|
|
assert "simulation_guidelines" in prompt
|
|
|
|
|
|
def test_simulated_user_agent_followup_with_simulation_guidelines():
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.return_value = "Let me clarify something first."
|
|
|
|
agent = SimulatedUserAgent()
|
|
context = SimulatorContext(
|
|
goal="Learn about ML",
|
|
persona=DEFAULT_PERSONA,
|
|
conversation_history=[
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
],
|
|
turn=1,
|
|
simulation_guidelines="Be thorough and ask follow-up questions",
|
|
)
|
|
|
|
message = agent.generate_message(context)
|
|
|
|
assert message == "Let me clarify something first."
|
|
mock_invoke.assert_called_once()
|
|
|
|
call_args = mock_invoke.call_args
|
|
messages = call_args.kwargs["messages"]
|
|
prompt = messages[0].content
|
|
|
|
assert "Be thorough and ask follow-up questions" in prompt
|
|
assert "simulation_guidelines" in prompt
|
|
|
|
|
|
def test_conversation_simulator_with_simulation_guidelines(mock_predict_fn):
|
|
test_case = {
|
|
"goal": "Learn about ML pipelines",
|
|
"simulation_guidelines": "Ask clarifying questions before proceeding",
|
|
}
|
|
|
|
with patch("mlflow.genai.simulators.simulator.invoke_model_without_tracing") as mock_invoke:
|
|
mock_invoke.side_effect = [
|
|
"Test message with simulation_guidelines",
|
|
'{"rationale": "Goal achieved!", "result": "yes"}',
|
|
]
|
|
|
|
simulator = ConversationSimulator(
|
|
test_cases=[test_case],
|
|
max_turns=2,
|
|
)
|
|
|
|
all_traces = simulator.simulate(mock_predict_fn)
|
|
|
|
assert len(all_traces) == 1
|
|
assert len(all_traces[0]) == 1
|
|
|
|
# Verify simulation_guidelines are in the generate_message prompt
|
|
generate_call = mock_invoke.call_args_list[0]
|
|
prompt = generate_call.kwargs["messages"][0].content
|
|
assert "Ask clarifying questions before proceeding" in prompt
|
|
|
|
trace = all_traces[0][0]
|
|
metadata = trace.info.trace_metadata
|
|
assert "mlflow.simulation.simulation_guidelines" in metadata
|
|
assert (
|
|
metadata["mlflow.simulation.simulation_guidelines"]
|
|
== "Ask clarifying questions before proceeding"
|
|
)
|
|
assert TraceMetadataKey.TRACE_SESSION in metadata
|