359 lines
15 KiB
Python
359 lines
15 KiB
Python
import json
|
|
from unittest.mock import patch
|
|
|
|
from odoo.addons.ai.utils.llm_api_service import LLMApiService
|
|
from odoo.exceptions import AccessError
|
|
from odoo.tests import TransactionCase, new_test_user, tagged
|
|
from odoo.tools import mute_logger
|
|
from odoo.addons.ai.models.ir_actions_server import _logger as tool_logger
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestAiServerActions(TransactionCase):
|
|
def _mock_llm_api_get_token(self):
|
|
def _mock_get_api_token(self):
|
|
return "dummy"
|
|
return patch.object(LLMApiService, '_get_api_token', _mock_get_api_token)
|
|
|
|
def test_ai_server_action_access(self):
|
|
"""Test that the group check is skipped on the tool, but not on the AI action."""
|
|
user = new_test_user(self.env, "internal_user_ai", "base.group_user,base.group_partner_manager")
|
|
partner = self.env["res.partner"].create({"name": "Partner"})
|
|
|
|
ir_action_tool = self.env["ir.actions.server"].create({
|
|
"model_id": self.env["ir.model"]._get_id("res.partner"),
|
|
"state": "code",
|
|
"name": "Write Name",
|
|
"use_in_ai": True,
|
|
"code": "record.write({'name': value})",
|
|
"group_ids": self.env.ref("base.group_system").ids,
|
|
})
|
|
action = self.env["ir.actions.server"].create(
|
|
{
|
|
"model_id": self.env["ir.model"]._get_id("res.partner"),
|
|
"state": "ai",
|
|
"name": "Test",
|
|
"ai_tool_ids": ir_action_tool.ids,
|
|
"ai_action_prompt": "Main Prompt",
|
|
},
|
|
)
|
|
llm_calls = 0
|
|
|
|
def _mocked_request_llm(
|
|
service, llm_model, system_prompts, user_prompts, tools=None,
|
|
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=None,
|
|
):
|
|
nonlocal llm_calls
|
|
llm_calls += 1
|
|
return ["Done"], [], []
|
|
|
|
# Check that we skip the group check on the tools
|
|
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
|
|
action.with_user(user).with_context(active_id=partner.id, active_model='res.partner').run()
|
|
|
|
self.assertEqual(llm_calls, 1)
|
|
|
|
# But not on the AI action
|
|
action.group_ids = self.env.ref("base.group_system").ids
|
|
llm_calls = 0
|
|
|
|
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm), self.assertRaises(AccessError):
|
|
action.with_user(user).with_context(active_id=partner.id, active_model='res.partner').run()
|
|
|
|
self.assertEqual(llm_calls, 0)
|
|
|
|
def test_ai_server_action_user_access(self):
|
|
user = new_test_user(self.env, "internal_user_ai", "base.group_user")
|
|
partner = self.env["res.partner"].create({"name": "Partner"})
|
|
|
|
# Remove res.partner read access from the user
|
|
access_records = self.env['ir.model.access'].search([
|
|
('model_id.model', '=', 'res.partner'),
|
|
('group_id', 'in', user.group_ids.ids)
|
|
])
|
|
access_records.write({'perm_read': False})
|
|
|
|
# Prepare the mocked LLM responses, openai format
|
|
mock_request = self._create_mock_request([
|
|
{
|
|
"output": [
|
|
{
|
|
"type": "function_call",
|
|
"name": "ir_actions_server_search",
|
|
"call_id": "call_1",
|
|
"arguments": json.dumps({"model_name": "res.partner", "domain": json.dumps([["id", "=", partner.id]]), "fields": ["name"]}),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"output": [
|
|
{"text": "Unable to fetch partner details due to access rights."},
|
|
],
|
|
},
|
|
])
|
|
|
|
# Fetch the ask_ai_agent record with elevated rights, just like they do in the controller
|
|
ask_ai_agent_sudo = self.env["ai.agent"]._get_potential_ask_ai_agent().with_user(user).sudo()
|
|
|
|
# Need to ensure the llm model is openai since the mocked request is for openai
|
|
ask_ai_agent_sudo.write({"llm_model": "gpt-4.1"})
|
|
|
|
with patch.object(LLMApiService, "_request", mock_request), \
|
|
patch.object(LLMApiService, "_get_api_token", return_value='dummy_token'), \
|
|
self.assertLogs(tool_logger, level='ERROR') as mock_tool_logger:
|
|
ask_ai_agent_sudo._generate_response("test")
|
|
|
|
# Single error log stating the user cannot access the records
|
|
error_log_record, = mock_tool_logger.records
|
|
self.assertIn("An error occurred while executing AI: Search: You are not allowed to access 'Contact' (res.partner) records.", error_log_record.msg)
|
|
|
|
@mute_logger("odoo.addons.ai.utils.llm_api_service")
|
|
def test_ai_server_action_ai_tool(self):
|
|
llm_calls = 0
|
|
|
|
def _mocked_request_llm(
|
|
service, llm_model, system_prompts, user_prompts, tools=None,
|
|
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=False,
|
|
):
|
|
nonlocal llm_calls
|
|
llm_calls += 1
|
|
tool_names = sorted(tools, key=lambda t: tools[t][0])
|
|
self.assertEqual(len(tools or ()), 2)
|
|
self.assertEqual(tool_names[0], f"action_{ir_action_tools[0].id}")
|
|
self.assertEqual(tools[tool_names[0]][0], ir_action_tools[0].name)
|
|
self.assertEqual(tool_names[1], f"action_{ir_action_tools[1].id}")
|
|
self.assertEqual(tools[tool_names[1]][0], ir_action_tools[1].name)
|
|
if llm_calls == 1:
|
|
self.assertFalse(inputs)
|
|
# Call "Return Value" and wait the result
|
|
return self._ai_tool_call(tool_names[0], "call_123456", {})
|
|
|
|
if llm_calls == 2:
|
|
self.assertEqual(len(inputs), 2)
|
|
self.assertEqual(inputs[0].get('call_id'), "call_123456")
|
|
self.assertEqual(inputs[1].get('call_id'), "call_123456")
|
|
self.assertEqual(inputs[1].get('output'), "133333337")
|
|
self.assertEqual(inputs[1].get('type'), "function_call_output")
|
|
return self._ai_tool_call(
|
|
tool_names[1],
|
|
"call_789123",
|
|
{"value": "new name", "__end_message": "Renamed!"},
|
|
)
|
|
|
|
return ["Done"], [], []
|
|
|
|
partner = self.env["res.partner"].create({"name": "Partner"})
|
|
|
|
ir_action_tools = self.env["ir.actions.server"].create([{
|
|
"model_id": self.env["ir.model"]._get_id("res.partner"),
|
|
"state": "code",
|
|
"name": "Return Value",
|
|
"use_in_ai": True,
|
|
"code": "ai['result'] = 133333337",
|
|
}, {
|
|
"model_id": self.env["ir.model"]._get_id("res.partner"),
|
|
"state": "code",
|
|
"name": "Write Name",
|
|
"use_in_ai": True,
|
|
"code": "record.write({'name': value})",
|
|
}])
|
|
|
|
action = self.env["ir.actions.server"].create(
|
|
{
|
|
"model_id": self.env["ir.model"]._get_id("res.partner"),
|
|
"state": "ai",
|
|
"name": "Test",
|
|
"ai_tool_ids": ir_action_tools.ids,
|
|
"ai_action_prompt": "Main Prompt",
|
|
},
|
|
)
|
|
|
|
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
|
|
action.with_context(active_model=partner._name, active_id=partner.id).run()
|
|
|
|
self.assertEqual(llm_calls, 2)
|
|
self.assertEqual(partner.name, "new name")
|
|
|
|
# Simulate the LLM answering a forbidden action (not in the tools)
|
|
bad_action = self.env["ir.actions.server"].create({
|
|
"model_id": self.env["ir.model"]._get_id("res.partner"),
|
|
"state": "code",
|
|
"name": "Bad Action",
|
|
"use_in_ai": True,
|
|
"code": "record.write({'name': 'bad'})",
|
|
})
|
|
|
|
llm_calls = 0
|
|
|
|
def _mocked_request_llm_bad_action(*args, **kwargs):
|
|
nonlocal llm_calls
|
|
llm_calls += 1
|
|
if llm_calls == 1:
|
|
return self._ai_tool_call(f"action_{bad_action.id}", "call_123456", {})
|
|
return ["Done"], [], []
|
|
|
|
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm_bad_action):
|
|
action.with_context(active_model=partner._name, active_id=partner.id).run()
|
|
|
|
self.assertEqual(llm_calls, 2)
|
|
self.assertEqual(partner.name, "new name", "Should not execute the action because it's not listed in the tools")
|
|
|
|
def _patched_can_execute_action_on_records(*__):
|
|
raise AccessError("")
|
|
|
|
with (
|
|
patch.object(self.env.registry['ir.actions.server'], "_can_execute_action_on_records", _patched_can_execute_action_on_records),
|
|
self.assertRaises(AccessError),
|
|
):
|
|
action.with_context(active_model=partner._name, active_id=partner.id).run()
|
|
|
|
# Check that if we mark a tool as "not used with AI" we don't send it
|
|
# to the LLM even if the m2m relation still exist
|
|
ir_action_tools[1].use_in_ai = False
|
|
partner.name = 'name'
|
|
llm_calls = 0
|
|
|
|
def _mocked_request_llm_use_in_ai_false(
|
|
service, llm_model, system_prompts, user_prompts, tools=None,
|
|
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=False,
|
|
):
|
|
nonlocal llm_calls
|
|
llm_calls += 1
|
|
self.assertEqual(len(tools), 1)
|
|
if llm_calls == 1:
|
|
# The LLM still try to execute it
|
|
return self._ai_tool_call(
|
|
f"action_{ir_action_tools[1].id}",
|
|
"call_789123",
|
|
{"value": "new name"},
|
|
)
|
|
return ["Done"], [], []
|
|
|
|
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm_use_in_ai_false):
|
|
action.with_context(active_model=partner._name, active_id=partner.id).run()
|
|
|
|
self.assertEqual(llm_calls, 2)
|
|
self.assertEqual(partner.name, "name", "The action is disabled and should not be executed")
|
|
|
|
@mute_logger("odoo.addons.ai.models.ir_actions_server")
|
|
def test_ai_server_action_ai_interactive_tool(self):
|
|
"""Check that we raise an error for interactive tools."""
|
|
llm_calls = 0
|
|
|
|
def _mocked_request_llm(
|
|
service, llm_model, system_prompts, user_prompts, tools=None,
|
|
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=False,
|
|
):
|
|
nonlocal llm_calls
|
|
llm_calls += 1
|
|
if llm_calls == 1:
|
|
self.assertFalse(inputs)
|
|
return self._ai_tool_call(f"action_{ir_action_tool.id}", "call_123456", {})
|
|
return ["Done"], [], []
|
|
|
|
partner = self.env["res.partner"].create({"name": "Partner"})
|
|
|
|
ir_action_tool = self.env["ir.actions.server"].create({
|
|
"model_id": self.env["ir.model"]._get_id("res.partner"),
|
|
"state": "code",
|
|
"name": "Return Value",
|
|
"use_in_ai": True,
|
|
# The action tries to open a window action
|
|
"code": """action = {
|
|
"type": "ir.actions.act_window",
|
|
"res_model": "res.partner",
|
|
"target": "new",
|
|
}
|
|
""",
|
|
})
|
|
|
|
action = self.env["ir.actions.server"].create(
|
|
{
|
|
"model_id": self.env["ir.model"]._get_id("res.partner"),
|
|
"state": "ai",
|
|
"name": "Test",
|
|
"ai_tool_ids": ir_action_tool.ids,
|
|
"ai_action_prompt": "Main Prompt",
|
|
},
|
|
)
|
|
|
|
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
|
|
action.with_context(active_model=partner._name, active_id=partner.id).run()
|
|
|
|
self.assertEqual(llm_calls, 2)
|
|
self.assertIn(
|
|
'This action is interactive and cannot be executed by the agent.',
|
|
''.join(partner.message_ids.mapped('body')),
|
|
'Should log the error on the partner',
|
|
)
|
|
|
|
def _ai_tool_call(self, name, call_id, arguments):
|
|
# Simulate the response of `_request_llm` when the LLM ask to execute a tool
|
|
return ["Done"], [(name, call_id, arguments)], [{"call_id": call_id, "name": name, "arguments": json.dumps(arguments)}]
|
|
|
|
def _create_mock_request(self, responses):
|
|
call_count = 0
|
|
|
|
def mock_request(*args, **kwargs):
|
|
nonlocal call_count
|
|
result = responses[call_count]
|
|
call_count += 1
|
|
return result
|
|
|
|
return mock_request
|
|
|
|
def test_ai_create_activity(self):
|
|
# check that activities can be created from an AI action
|
|
create_activity_action = self.env['ir.actions.server'].create({
|
|
'model_id': self.env['ir.model']._get_id('res.partner'),
|
|
'state': 'next_activity',
|
|
'name': 'create activity',
|
|
'use_in_ai': True,
|
|
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
|
'activity_note': 'created by AI',
|
|
})
|
|
ai_action = self.env['ir.actions.server'].create({
|
|
'model_id': self.env['ir.model']._get_id('res.partner'),
|
|
'state': 'ai',
|
|
'name': 'call create activity',
|
|
'ai_action_prompt': 'create an activity',
|
|
'ai_tool_ids': create_activity_action.ids,
|
|
})
|
|
|
|
def _mocked_request_llm(*args, **kwargs):
|
|
return self._ai_tool_call(f"action_{create_activity_action.id}", "call_111111", {})
|
|
|
|
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
|
|
ai_action._ai_action_run(self.env.user.partner_id)
|
|
self.assertIn('created by AI', self.env.user.partner_id.activity_ids[0].note)
|
|
|
|
def test_ai_send_mail(self):
|
|
partner_model_id = self.env['ir.model']._get_id('res.partner')
|
|
send_mail_action = self.env['ir.actions.server'].create({
|
|
'model_id': partner_model_id,
|
|
'state': 'mail_post',
|
|
'name': 'send mail',
|
|
'use_in_ai': True,
|
|
'template_id': self.env['mail.template'].create({
|
|
'name': 'Test template',
|
|
'model_id': partner_model_id,
|
|
'subject': 'mail sent by AI',
|
|
}).id
|
|
})
|
|
ai_action = self.env['ir.actions.server'].create({
|
|
'model_id': partner_model_id,
|
|
'state': 'ai',
|
|
'name': 'send mail',
|
|
'ai_action_prompt': 'send an email',
|
|
'ai_tool_ids': send_mail_action.ids,
|
|
})
|
|
|
|
def _mocked_request_llm(*args, **kwargs):
|
|
return self._ai_tool_call(f"action_{send_mail_action.id}", "call_111111", {})
|
|
|
|
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
|
|
ai_action._ai_action_run(self.env.user.partner_id)
|
|
|
|
# check that the mail has been created
|
|
self.assertTrue(any(msg.subject == 'mail sent by AI' for msg in self.env.user.partner_id.message_ids))
|