From 0b9cd71c5712fe5d7ef7dd3f4b781336833242ae Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Mon, 20 Apr 2026 09:17:39 +0200 Subject: [PATCH 1/3] [ADD] ai_automation --- ai_server_action/README.rst | 108 +++++ ai_server_action/__init__.py | 1 + ai_server_action/__manifest__.py | 21 + ai_server_action/models/__init__.py | 2 + ai_server_action/models/ai_connection.py | 73 +++ ai_server_action/models/ir_actions_server.py | 72 +++ ai_server_action/readme/CONTRIBUTORS.md | 2 + ai_server_action/readme/DESCRIPTION.md | 3 + ai_server_action/readme/USAGE.md | 14 + ai_server_action/security/ir.model.access.csv | 3 + ai_server_action/static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 458 ++++++++++++++++++ ai_server_action/tests/__init__.py | 1 + .../tests/test_connection_ollama.py | 134 +++++ ai_server_action/views/ai_connection.xml | 72 +++ ai_server_action/views/ir_actions_server.xml | 42 ++ 16 files changed, 1006 insertions(+) create mode 100644 ai_server_action/README.rst create mode 100644 ai_server_action/__init__.py create mode 100644 ai_server_action/__manifest__.py create mode 100644 ai_server_action/models/__init__.py create mode 100644 ai_server_action/models/ai_connection.py create mode 100644 ai_server_action/models/ir_actions_server.py create mode 100644 ai_server_action/readme/CONTRIBUTORS.md create mode 100644 ai_server_action/readme/DESCRIPTION.md create mode 100644 ai_server_action/readme/USAGE.md create mode 100644 ai_server_action/security/ir.model.access.csv create mode 100644 ai_server_action/static/description/icon.png create mode 100644 ai_server_action/static/description/index.html create mode 100644 ai_server_action/tests/__init__.py create mode 100644 ai_server_action/tests/test_connection_ollama.py create mode 100644 ai_server_action/views/ai_connection.xml create mode 100644 ai_server_action/views/ir_actions_server.xml diff --git a/ai_server_action/README.rst b/ai_server_action/README.rst new file mode 100644 index 00000000..cf7a7923 --- /dev/null +++ b/ai_server_action/README.rst @@ -0,0 +1,108 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============= +Ai Automation +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9db8aa67499c9f7efc73ff5f14809ca6d3df0f99e4eb35df8fff7f4cd3de79d1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github + :target: https://github.com/OCA/ai/tree/16.0/ai_automation + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-16-0/ai-16-0-ai_automation + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates AI connections with Odoo server actions, allowing +you to define AI-powered automations. By default it provides Ollama +support, but it can be extended with additional providers by adding new +``kind`` options to ``ai.connection``. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module adds a new server action type: **AI OCA Action**. + +To use it: + +1. Go to ``Settings > Technical > Actions > Server Actions`` +2. Create a new action and select **AI OCA Action** as the state +3. Select an AI Connection (Ollama) +4. Define the prompt — supports dynamic placeholders using Qweb syntax + (e.g. ``{{ object.name }}``) +5. Optionally select tools the AI can call during execution +6. Define what to do with the result: + + - **Post Message**: posts the AI response as a chatter message on the + record + - **Update Record**: writes the AI response to a specific field + +To extend with a new AI provider, inherit ``ai.connection`` and add a +new selection value to ``kind``, then implement the corresponding +``_run_{kind}`` method. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Dixmit + +Contributors +------------ + +- `Dixmit `__ + + - Enric Tobella + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/ai `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_server_action/__init__.py b/ai_server_action/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/ai_server_action/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/ai_server_action/__manifest__.py b/ai_server_action/__manifest__.py new file mode 100644 index 00000000..07d0262b --- /dev/null +++ b/ai_server_action/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Ai Automation", + "summary": """Integrate `ai_tools` with server actions to automate tasks using AI.""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "depends": ["ai_tool"], + "external_dependencies": { + "python": ["ollama"], + }, + "data": [ + "views/ir_actions_server.xml", + "security/ir.model.access.csv", + "views/ai_connection.xml", + ], + "demo": [], +} diff --git a/ai_server_action/models/__init__.py b/ai_server_action/models/__init__.py new file mode 100644 index 00000000..f2ab448f --- /dev/null +++ b/ai_server_action/models/__init__.py @@ -0,0 +1,2 @@ +from . import ai_connection +from . import ir_actions_server diff --git a/ai_server_action/models/ai_connection.py b/ai_server_action/models/ai_connection.py new file mode 100644 index 00000000..0419c58d --- /dev/null +++ b/ai_server_action/models/ai_connection.py @@ -0,0 +1,73 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +import ollama + +from odoo import fields, models + + +class AiConnection(models.Model): + _name = "ai.connection" + _description = "AI Connection" + + name = fields.Char(required=True) + kind = fields.Selection([("ollama", "Ollama")], required=True, default="ollama") + active = fields.Boolean(default=True) + url = fields.Char(groups="base.group_system") + model = fields.Char(groups="base.group_system") + + def _run(self, prompt, tools=None, record=None): + return getattr(self, f"_run_{self.kind}")(prompt, tools=tools, record=record) + + def _run_ollama(self, prompt, tools=None, messages=None, record=None): + tool_definition = [] + for tool in tools or []: + definition = tool._get_tool_definition() + input_schema = definition["inputSchema"] + input_schema["additionalProperties"] = False + tool_definition.append( + { + "type": "function", + "function": { + "name": definition["name"], + "description": definition["description"], + "parameters": input_schema, + }, + } + ) + ollama_client = ollama.Client(**self._get_ollama_client_parameters()) + if messages is None: + messages = [] + messages.append({"role": "user", "content": prompt}) + while True: + response = ollama_client.chat( + model=self.model, + messages=messages, + tools=tool_definition, + ) + if not response.message.tool_calls: + return response.message.content + messages.append(response.message) + for call in response.message.tool_calls: + tool = tools.filtered(lambda t: t.name == call.function.name) + tool_output = tool._execute_tool( + record=record, **call.function.arguments + ) + if isinstance(tool_output, dict): + tool_output = json.dumps(tool_output) + messages.append( + { + "role": "tool", + "tool_name": call.function.name, + "content": tool_output, + } + ) + + def _get_ollama_client_parameters(self): + """ + We provide this hook so people can modify the client and other configurations + like headers and so on. + """ + return {"host": self.url, "headers": {}} diff --git a/ai_server_action/models/ir_actions_server.py b/ai_server_action/models/ir_actions_server.py new file mode 100644 index 00000000..cbcb4f88 --- /dev/null +++ b/ai_server_action/models/ir_actions_server.py @@ -0,0 +1,72 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from lxml import etree + +from odoo import api, fields, models + +from odoo.addons.web_editor.models.ir_qweb_fields import html_to_text + + +class IrActionsServer(models.Model): + + _inherit = "ir.actions.server" + + state = fields.Selection( + selection_add=[("ai_oca", "AI OCA Action")], ondelete={"ai_oca": "cascade"} + ) + ai_connection_id = fields.Many2one( + "ai.connection", string="AI Connection", groups="base.group_system" + ) + ai_tool_ids = fields.Many2many( + "ai.tool", + string="AI Tools", + groups="base.group_system", + ) + ai_prompt = fields.Html(string="AI Prompt", sanitize=False) + mailing_model_real = fields.Char(compute="_compute_mailing_model_real") + ai_result_action = fields.Selection( + [ + ("post_message", "Post Message"), + ("update_record", "Update Record"), + ], + string="AI Result Action", + ) + ai_update_record_field_id = fields.Many2one( + "ir.model.fields", + string="AI Update Record Field", + domain="[('model_id', '=', model_id), ('ttype', 'in', ['char', 'text'])]", + ) + + @api.depends("model_id") + def _compute_mailing_model_real(self): + for record in self: + record.mailing_model_real = ( + record.model_id.model if record.model_id else False + ) + + def _run_action_ai_oca(self, eval_context=None): + record = eval_context.get("record") + result = self.ai_connection_id._run( + self._get_ai_oca_prompt(record), tools=self.ai_tool_ids, record=record + ) + self._post_run_action_ai_oca(result, record) + + def _post_run_action_ai_oca(self, result, record): + if self.ai_result_action == "post_message": + self.env["ai.tool"]._ai_post_message(result, record=record) + elif ( + self.ai_result_action == "update_record" + and record + and self.ai_update_record_field_id + ): + record.write({self.ai_update_record_field_id.name: result}) + + def _get_ai_oca_prompt(self, record): + ai_prompt = self.ai_prompt + if record: + ai_prompt = str( + self.env["mail.render.mixin"]._render_template_qweb( + self.ai_prompt, record and record._name, record and record.ids + )[record.id] + ) + return html_to_text(etree.fromstring("" + ai_prompt + "")) diff --git a/ai_server_action/readme/CONTRIBUTORS.md b/ai_server_action/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2c066ba7 --- /dev/null +++ b/ai_server_action/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/ai_server_action/readme/DESCRIPTION.md b/ai_server_action/readme/DESCRIPTION.md new file mode 100644 index 00000000..7fa64139 --- /dev/null +++ b/ai_server_action/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module integrates AI connections with Odoo server actions, allowing you to +define AI-powered automations. By default it provides Ollama support, but it can +be extended with additional providers by adding new `kind` options to `ai.connection`. diff --git a/ai_server_action/readme/USAGE.md b/ai_server_action/readme/USAGE.md new file mode 100644 index 00000000..e597ad9a --- /dev/null +++ b/ai_server_action/readme/USAGE.md @@ -0,0 +1,14 @@ +This module adds a new server action type: **AI OCA Action**. + +To use it: +1. Go to `Settings > Technical > Actions > Server Actions` +2. Create a new action and select **AI OCA Action** as the state +3. Select an AI Connection (Ollama) +4. Define the prompt — supports dynamic placeholders using Qweb syntax (e.g. `{{ object.name }}`) +5. Optionally select tools the AI can call during execution +6. Define what to do with the result: + - **Post Message**: posts the AI response as a chatter message on the record + - **Update Record**: writes the AI response to a specific field + +To extend with a new AI provider, inherit `ai.connection` and add a new selection +value to `kind`, then implement the corresponding `_run_{kind}` method. \ No newline at end of file diff --git a/ai_server_action/security/ir.model.access.csv b/ai_server_action/security/ir.model.access.csv new file mode 100644 index 00000000..9d762bd9 --- /dev/null +++ b/ai_server_action/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_connection,access_ai_connection,model_ai_connection,base.group_user,1,0,0,0 +manage_ai_connection,manage_ai_connection,model_ai_connection,base.group_system,1,1,1,0 diff --git a/ai_server_action/static/description/icon.png b/ai_server_action/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/ai_server_action/static/description/index.html b/ai_server_action/static/description/index.html new file mode 100644 index 00000000..c8b600b8 --- /dev/null +++ b/ai_server_action/static/description/index.html @@ -0,0 +1,458 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Ai Automation

+ +

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

This module integrates AI connections with Odoo server actions, allowing +you to define AI-powered automations. By default it provides Ollama +support, but it can be extended with additional providers by adding new +kind options to ai.connection.

+

Table of contents

+ +
+

Usage

+

This module adds a new server action type: AI OCA Action.

+

To use it:

+
    +
  1. Go to Settings > Technical > Actions > Server Actions
  2. +
  3. Create a new action and select AI OCA Action as the state
  4. +
  5. Select an AI Connection (Ollama)
  6. +
  7. Define the prompt — supports dynamic placeholders using Qweb syntax +(e.g. {{ object.name }})
  8. +
  9. Optionally select tools the AI can call during execution
  10. +
  11. Define what to do with the result:
      +
    • Post Message: posts the AI response as a chatter message on the +record
    • +
    • Update Record: writes the AI response to a specific field
    • +
    +
  12. +
+

To extend with a new AI provider, inherit ai.connection and add a +new selection value to kind, then implement the corresponding +_run_{kind} method.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Dixmit
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/ai project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/ai_server_action/tests/__init__.py b/ai_server_action/tests/__init__.py new file mode 100644 index 00000000..f1aa56d4 --- /dev/null +++ b/ai_server_action/tests/__init__.py @@ -0,0 +1 @@ +from . import test_connection_ollama diff --git a/ai_server_action/tests/test_connection_ollama.py b/ai_server_action/tests/test_connection_ollama.py new file mode 100644 index 00000000..93335c27 --- /dev/null +++ b/ai_server_action/tests/test_connection_ollama.py @@ -0,0 +1,134 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest import mock + +from ollama._types import ChatResponse + +from odoo.tests.common import TransactionCase + + +class OllamaClient: + def __init__(self, chat_messages=None): + self.chat_messages = chat_messages or [] + self.current_message = -1 + self.calls = [] + + def chat(self, *args, **kwargs): + self.current_message += 1 + self.calls.append((args, kwargs)) + return ChatResponse.model_validate( + {"message": self.chat_messages[self.current_message]} + ) + + +class TestConnectionOllama(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.connection = cls.env["ai.connection"].create( + { + "name": "Ollama Connection", + "url": "http://my_ollama_server:11434", + "model": "my_ollama_model", + "kind": "ollama", + } + ) + cls.action = cls.env["ir.actions.server"].create( + { + "name": "Test Action", + "model_id": cls.env.ref("base.model_res_partner").id, + "state": "ai_oca", + "ai_connection_id": cls.connection.id, + "ai_tool_ids": [(4, cls.env.ref("ai_tool.current_date").id)], + "ai_prompt": "What is the current date?", + } + ) + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + + def test_execute_action_with_tools(self): + client = OllamaClient( + [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "function": { + "name": "get_date", + "arguments": {}, + }, + "id": "1", + } + ], + }, + { + "role": "assistant", + "content": "Thanks it is 2024-01-01", + }, + ] + ) + with mock.patch("ollama.Client", return_value=client): + messages = self.partner.message_ids + self.action.with_context( + active_id=self.partner.id, active_model="res.partner" + ).run() + self.assertEqual(client.current_message, 1) + self.assertEqual(messages, self.partner.message_ids) + + def test_execute_action_post_message(self): + client = OllamaClient( + [ + { + "role": "assistant", + "content": "Thanks it is 2024-01-01", + }, + ] + ) + self.action.ai_result_action = "post_message" + with mock.patch("ollama.Client", return_value=client): + messages = self.partner.message_ids + self.action.with_context( + active_id=self.partner.id, active_model="res.partner" + ).run() + self.assertEqual(client.current_message, 0) + self.assertEqual(len(messages) + 1, len(self.partner.message_ids)) + self.assertRegex( + (self.partner.message_ids - messages).body, r".*2024-01-01.*" + ) + + def test_execute_action_field(self): + client = OllamaClient( + [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "function": { + "name": "get_date", + "arguments": {}, + }, + "id": "1", + } + ], + }, + { + "role": "assistant", + "content": "Thanks it is 2024-01-01", + }, + ] + ) + self.action.ai_result_action = "update_record" + self.action.ai_update_record_field_id = self.env.ref( + "base.field_res_partner__comment" + ) + self.partner.comment = "Initial Comment" + with mock.patch("ollama.Client", return_value=client): + messages = self.partner.message_ids + self.action.with_context( + active_id=self.partner.id, active_model="res.partner" + ).run() + self.assertEqual(client.current_message, 1) + self.assertEqual(messages, self.partner.message_ids) + self.assertRegex(self.partner.comment, r".*2024-01-01.*") diff --git a/ai_server_action/views/ai_connection.xml b/ai_server_action/views/ai_connection.xml new file mode 100644 index 00000000..acf18c19 --- /dev/null +++ b/ai_server_action/views/ai_connection.xml @@ -0,0 +1,72 @@ + + + + + + ai.connection + +
+
+ + + + + + + + + + + + + + + + + + + + ai.connection + + + + + + + + + ai.connection + + + + + + + + + + AI Connection + ai.connection + tree,form + [] + {} + + + + AI Connection + + + + + + diff --git a/ai_server_action/views/ir_actions_server.xml b/ai_server_action/views/ir_actions_server.xml new file mode 100644 index 00000000..cc046e66 --- /dev/null +++ b/ai_server_action/views/ir_actions_server.xml @@ -0,0 +1,42 @@ + + + + + + ir.actions.server + + + + + + + + + + + + + + + + + + + + From d59dd6d4e435899e42378c8610b93573f9bc45c3 Mon Sep 17 00:00:00 2001 From: amoya Date: Tue, 23 Jun 2026 11:33:15 +0200 Subject: [PATCH 2/3] [IMP] ai_automation: pre-commit auto fixes --- ai_server_action/models/ir_actions_server.py | 1 - ai_server_action/pyproject.toml | 3 +++ ai_server_action/views/ai_connection.xml | 2 -- ai_server_action/views/ir_actions_server.xml | 4 ---- 4 files changed, 3 insertions(+), 7 deletions(-) create mode 100644 ai_server_action/pyproject.toml diff --git a/ai_server_action/models/ir_actions_server.py b/ai_server_action/models/ir_actions_server.py index cbcb4f88..ce65099b 100644 --- a/ai_server_action/models/ir_actions_server.py +++ b/ai_server_action/models/ir_actions_server.py @@ -8,7 +8,6 @@ class IrActionsServer(models.Model): - _inherit = "ir.actions.server" state = fields.Selection( diff --git a/ai_server_action/pyproject.toml b/ai_server_action/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_server_action/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_server_action/views/ai_connection.xml b/ai_server_action/views/ai_connection.xml index acf18c19..bfb4b8be 100644 --- a/ai_server_action/views/ai_connection.xml +++ b/ai_server_action/views/ai_connection.xml @@ -2,7 +2,6 @@ - ai.connection @@ -68,5 +67,4 @@ - diff --git a/ai_server_action/views/ir_actions_server.xml b/ai_server_action/views/ir_actions_server.xml index cc046e66..11836ff3 100644 --- a/ai_server_action/views/ir_actions_server.xml +++ b/ai_server_action/views/ir_actions_server.xml @@ -2,7 +2,6 @@ - ir.actions.server @@ -36,7 +35,4 @@ - - - From 69bcfb1192aac0a40874315553f1d0c02bc895a9 Mon Sep 17 00:00:00 2001 From: amoya Date: Sat, 11 Jul 2026 09:50:55 +0200 Subject: [PATCH 3/3] [MIG] ai_automation: migration to 18.0 --- ai_server_action/README.rst | 18 ++- ai_server_action/__manifest__.py | 11 +- ai_server_action/models/__init__.py | 1 - ai_server_action/models/ai_connection.py | 73 ---------- ai_server_action/models/ir_actions_server.py | 101 +++++++++---- ai_server_action/readme/CONTRIBUTORS.md | 1 + ai_server_action/security/ir.model.access.csv | 3 - .../static/description/index.html | 31 ++-- ai_server_action/tests/__init__.py | 2 +- .../tests/test_ai_server_action.py | 125 ++++++++++++++++ .../tests/test_connection_ollama.py | 134 ------------------ ai_server_action/views/ai_connection.xml | 70 --------- ai_server_action/views/ir_actions_server.xml | 32 +++-- 13 files changed, 242 insertions(+), 360 deletions(-) delete mode 100644 ai_server_action/models/ai_connection.py delete mode 100644 ai_server_action/security/ir.model.access.csv create mode 100644 ai_server_action/tests/test_ai_server_action.py delete mode 100644 ai_server_action/tests/test_connection_ollama.py delete mode 100644 ai_server_action/views/ai_connection.xml diff --git a/ai_server_action/README.rst b/ai_server_action/README.rst index cf7a7923..1201c26b 100644 --- a/ai_server_action/README.rst +++ b/ai_server_action/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============= Ai Automation ============= @@ -17,17 +13,17 @@ Ai Automation .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fai-lightgray.png?logo=github - :target: https://github.com/OCA/ai/tree/16.0/ai_automation + :target: https://github.com/OCA/ai/tree/18.0/ai_server_action :alt: OCA/ai .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/ai-16-0/ai-16-0-ai_automation + :target: https://translation.odoo-community.org/projects/ai-18-0/ai-18-0-ai_server_action :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=16.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -71,7 +67,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -90,6 +86,8 @@ Contributors - Enric Tobella +- [SDi] (https://sdi.es) + Maintainers ----------- @@ -103,6 +101,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/ai `_ project on GitHub. +This module is part of the `OCA/ai `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/ai_server_action/__manifest__.py b/ai_server_action/__manifest__.py index 07d0262b..ff283ad0 100644 --- a/ai_server_action/__manifest__.py +++ b/ai_server_action/__manifest__.py @@ -3,19 +3,14 @@ { "name": "Ai Automation", - "summary": """Integrate `ai_tools` with server actions to automate tasks using AI.""", - "version": "16.0.1.0.0", + "summary": "Integrate `ai_tools` with server actions to automate tasks using AI.", + "version": "18.0.1.0.0", "license": "AGPL-3", "author": "Dixmit,Odoo Community Association (OCA)", "website": "https://github.com/OCA/ai", - "depends": ["ai_tool"], - "external_dependencies": { - "python": ["ollama"], - }, + "depends": ["ai_connection", "ai_tool"], "data": [ "views/ir_actions_server.xml", - "security/ir.model.access.csv", - "views/ai_connection.xml", ], "demo": [], } diff --git a/ai_server_action/models/__init__.py b/ai_server_action/models/__init__.py index f2ab448f..4ab5cb10 100644 --- a/ai_server_action/models/__init__.py +++ b/ai_server_action/models/__init__.py @@ -1,2 +1 @@ -from . import ai_connection from . import ir_actions_server diff --git a/ai_server_action/models/ai_connection.py b/ai_server_action/models/ai_connection.py deleted file mode 100644 index 0419c58d..00000000 --- a/ai_server_action/models/ai_connection.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright 2026 Dixmit -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -import json - -import ollama - -from odoo import fields, models - - -class AiConnection(models.Model): - _name = "ai.connection" - _description = "AI Connection" - - name = fields.Char(required=True) - kind = fields.Selection([("ollama", "Ollama")], required=True, default="ollama") - active = fields.Boolean(default=True) - url = fields.Char(groups="base.group_system") - model = fields.Char(groups="base.group_system") - - def _run(self, prompt, tools=None, record=None): - return getattr(self, f"_run_{self.kind}")(prompt, tools=tools, record=record) - - def _run_ollama(self, prompt, tools=None, messages=None, record=None): - tool_definition = [] - for tool in tools or []: - definition = tool._get_tool_definition() - input_schema = definition["inputSchema"] - input_schema["additionalProperties"] = False - tool_definition.append( - { - "type": "function", - "function": { - "name": definition["name"], - "description": definition["description"], - "parameters": input_schema, - }, - } - ) - ollama_client = ollama.Client(**self._get_ollama_client_parameters()) - if messages is None: - messages = [] - messages.append({"role": "user", "content": prompt}) - while True: - response = ollama_client.chat( - model=self.model, - messages=messages, - tools=tool_definition, - ) - if not response.message.tool_calls: - return response.message.content - messages.append(response.message) - for call in response.message.tool_calls: - tool = tools.filtered(lambda t: t.name == call.function.name) - tool_output = tool._execute_tool( - record=record, **call.function.arguments - ) - if isinstance(tool_output, dict): - tool_output = json.dumps(tool_output) - messages.append( - { - "role": "tool", - "tool_name": call.function.name, - "content": tool_output, - } - ) - - def _get_ollama_client_parameters(self): - """ - We provide this hook so people can modify the client and other configurations - like headers and so on. - """ - return {"host": self.url, "headers": {}} diff --git a/ai_server_action/models/ir_actions_server.py b/ai_server_action/models/ir_actions_server.py index ce65099b..13b47954 100644 --- a/ai_server_action/models/ir_actions_server.py +++ b/ai_server_action/models/ir_actions_server.py @@ -1,39 +1,67 @@ # Copyright 2026 Dixmit +# Copyright 2026 SDi - Ángel Moya # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from lxml import etree from odoo import api, fields, models +from odoo.tools.mail import html2plaintext, html_sanitize, plaintext2html -from odoo.addons.web_editor.models.ir_qweb_fields import html_to_text +try: + import markdown +except ImportError: + markdown = None class IrActionsServer(models.Model): _inherit = "ir.actions.server" state = fields.Selection( - selection_add=[("ai_oca", "AI OCA Action")], ondelete={"ai_oca": "cascade"} + selection_add=[("ai_oca", "Run AI Prompt")], + ondelete={"ai_oca": "cascade"}, ) ai_connection_id = fields.Many2one( - "ai.connection", string="AI Connection", groups="base.group_system" + "ai.connection", + string="AI Connection", + groups="base.group_system", ) ai_tool_ids = fields.Many2many( "ai.tool", string="AI Tools", groups="base.group_system", ) - ai_prompt = fields.Html(string="AI Prompt", sanitize=False) - mailing_model_real = fields.Char(compute="_compute_mailing_model_real") - ai_result_action = fields.Selection( + ai_prompt = fields.Html( + string="AI Prompt", + sanitize=False, + ) + mailing_model_real = fields.Char( + compute="_compute_mailing_model_real", + ) + ai_output_mode = fields.Selection( [ ("post_message", "Post Message"), ("update_record", "Update Record"), + ("store_variable", "Store in Context Variable"), + ("none", "None"), ], - string="AI Result Action", + string="Output Mode", + default="none", + groups="base.group_system", ) ai_update_record_field_id = fields.Many2one( "ir.model.fields", - string="AI Update Record Field", - domain="[('model_id', '=', model_id), ('ttype', 'in', ['char', 'text'])]", + string="Update Record Field", + domain=( + "[('model_id', '=', model_id), " + "('ttype', 'in', ['char', 'text', 'html'])]" + ), + groups="base.group_system", + ) + ai_context_variable = fields.Char( + string="Context Variable", + groups="base.group_system", + help=( + "Name of the variable where the response will be stored " + "in the evaluation context." + ), ) @api.depends("model_id") @@ -44,28 +72,45 @@ def _compute_mailing_model_real(self): ) def _run_action_ai_oca(self, eval_context=None): + self.ensure_one() + eval_context = eval_context or {} record = eval_context.get("record") + prompt = self._get_ai_prompt(record) result = self.ai_connection_id._run( - self._get_ai_oca_prompt(record), tools=self.ai_tool_ids, record=record - ) - self._post_run_action_ai_oca(result, record) - - def _post_run_action_ai_oca(self, result, record): - if self.ai_result_action == "post_message": - self.env["ai.tool"]._ai_post_message(result, record=record) - elif ( - self.ai_result_action == "update_record" - and record - and self.ai_update_record_field_id - ): - record.write({self.ai_update_record_field_id.name: result}) + prompt, + tools=self.ai_tool_ids, + record=record, + )[0] + self._post_run_action_ai_run(result, eval_context, record) - def _get_ai_oca_prompt(self, record): - ai_prompt = self.ai_prompt + def _get_ai_prompt(self, record): + self.ensure_one() + prompt = self.ai_prompt or "" if record: - ai_prompt = str( + prompt = str( self.env["mail.render.mixin"]._render_template_qweb( - self.ai_prompt, record and record._name, record and record.ids + prompt, record._name, record.ids )[record.id] ) - return html_to_text(etree.fromstring("" + ai_prompt + "")) + return html2plaintext(prompt) + + def _post_run_action_ai_run(self, result, eval_context, record): + self.ensure_one() + if self.ai_output_mode == "post_message" and record and record.exists(): + body = self._prepare_message_body(result) + record.message_post(body=body) + elif self.ai_output_mode == "update_record": + if record and self.ai_update_record_field_id: + if markdown and self.ai_update_record_field_id.ttype == "html": + value = self._prepare_message_body(result) + else: + value = result + record.write({self.ai_update_record_field_id.name: value}) + elif self.ai_output_mode == "store_variable": + if self.ai_context_variable: + eval_context[self.ai_context_variable] = result + + def _prepare_message_body(self, result): + if markdown: + return html_sanitize(markdown.markdown(result)) + return plaintext2html(result) diff --git a/ai_server_action/readme/CONTRIBUTORS.md b/ai_server_action/readme/CONTRIBUTORS.md index 2c066ba7..fed46661 100644 --- a/ai_server_action/readme/CONTRIBUTORS.md +++ b/ai_server_action/readme/CONTRIBUTORS.md @@ -1,2 +1,3 @@ - [Dixmit](https://www.dixmit.com) - Enric Tobella +- [SDi] (https://sdi.es) \ No newline at end of file diff --git a/ai_server_action/security/ir.model.access.csv b/ai_server_action/security/ir.model.access.csv deleted file mode 100644 index 9d762bd9..00000000 --- a/ai_server_action/security/ir.model.access.csv +++ /dev/null @@ -1,3 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_ai_connection,access_ai_connection,model_ai_connection,base.group_user,1,0,0,0 -manage_ai_connection,manage_ai_connection,model_ai_connection,base.group_system,1,1,1,0 diff --git a/ai_server_action/static/description/index.html b/ai_server_action/static/description/index.html index c8b600b8..2a64c80f 100644 --- a/ai_server_action/static/description/index.html +++ b/ai_server_action/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Ai Automation -
+
+

Ai Automation

- - -Odoo Community Association - -
-

Ai Automation

-

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/ai Translate me on Weblate Try me on Runboat

This module integrates AI connections with Odoo server actions, allowing you to define AI-powered automations. By default it provides Ollama support, but it can be extended with additional providers by adding new @@ -393,7 +388,7 @@

Ai Automation

-

Usage

+

Usage

This module adds a new server action type: AI OCA Action.

To use it:

    @@ -415,32 +410,33 @@

    Usage

    _run_{kind} method.

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Dixmit
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -448,11 +444,10 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

This module is part of the OCA/ai project on GitHub.

+

This module is part of the OCA/ai project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/ai_server_action/tests/__init__.py b/ai_server_action/tests/__init__.py index f1aa56d4..1460b40b 100644 --- a/ai_server_action/tests/__init__.py +++ b/ai_server_action/tests/__init__.py @@ -1 +1 @@ -from . import test_connection_ollama +from . import test_ai_server_action diff --git a/ai_server_action/tests/test_ai_server_action.py b/ai_server_action/tests/test_ai_server_action.py new file mode 100644 index 00000000..8bdce4fb --- /dev/null +++ b/ai_server_action/tests/test_ai_server_action.py @@ -0,0 +1,125 @@ +from unittest.mock import patch + +from odoo.tests.common import TransactionCase + +from odoo.addons.ai_server_action.models import ir_actions_server + + +class TestAiServerAction(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_model = cls.env["ir.model"].search( + [("model", "=", "res.partner")], limit=1 + ) + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + + def _create_action(self, output_mode="none"): + return self.env["ir.actions.server"].create( + { + "name": "Test AI Action", + "model_id": self.partner_model.id, + "state": "ai_oca", + "ai_prompt": "Say hello", + "ai_output_mode": output_mode, + } + ) + + def _run_action_with_mock(self, action): + """Execute action with _run patched on the ai.connection model.""" + with patch( + "odoo.addons.ai_connection.models.ai_connection.AiConnection._run", + return_value=("This is a mocked response", 10, 5, 1), + ): + return action.with_context( + active_id=self.partner.id, active_model="res.partner" + ).run() + + def test_action_state_added(self): + states = self.env["ir.actions.server"].fields_get(["state"]) + self.assertTrue(any(s[0] == "ai_oca" for s in states["state"]["selection"])) + + def test_mailing_model_real(self): + action = self._create_action() + self.assertEqual(action.mailing_model_real, "res.partner") + action_without_model = self.env["ir.actions.server"].new( + {"name": "Test AI Action", "state": "ai_oca"} + ) + self.assertFalse(action_without_model.mailing_model_real) + + def test_get_ai_prompt_without_record(self): + action = self._create_action() + self.assertEqual(action._get_ai_prompt(None), "Say hello") + + def test_get_ai_prompt_with_record(self): + action = self._create_action() + action.ai_prompt = "

Hello

" + self.assertEqual(action._get_ai_prompt(self.partner), "Hello Test Partner") + + def test_action_post_message(self): + action = self._create_action("post_message") + messages = self.partner.message_ids + self._run_action_with_mock(action) + new_msgs = self.partner.message_ids - messages + self.assertEqual(len(new_msgs), 1) + self.assertIn("mocked response", new_msgs.body) + + def test_action_post_message_with_markdown(self): + action = self._create_action("post_message") + messages = self.partner.message_ids + with patch.object(ir_actions_server, "markdown") as mock_markdown: + mock_markdown.markdown.side_effect = lambda text: f"

{text}

" + self._run_action_with_mock(action) + new_msgs = self.partner.message_ids - messages + self.assertEqual(len(new_msgs), 1) + self.assertIn("

", new_msgs.body) + self.assertIn("mocked response", new_msgs.body) + + def test_action_update_record(self): + field = self.env["ir.model.fields"].search( + [("model_id", "=", self.partner_model.id), ("name", "=", "comment")], + limit=1, + ) + action = self._create_action("update_record") + action.ai_update_record_field_id = field.id + self._run_action_with_mock(action) + self.assertIn("mocked response", self.partner.comment) + + def test_action_update_record_html_with_markdown(self): + field = self.env["ir.model.fields"].search( + [("model_id", "=", self.partner_model.id), ("name", "=", "comment")], + limit=1, + ) + self.assertEqual(field.ttype, "html") + action = self._create_action("update_record") + action.ai_update_record_field_id = field.id + with patch.object(ir_actions_server, "markdown") as mock_markdown: + mock_markdown.markdown.side_effect = lambda text: f"

{text}

" + self._run_action_with_mock(action) + self.assertIn("

", self.partner.comment) + self.assertIn("mocked response", self.partner.comment) + + def test_action_none(self): + action = self._create_action("none") + self._run_action_with_mock(action) + + def test_action_store_variable(self): + action = self._create_action("store_variable") + action.ai_context_variable = "ai_result" + eval_context = {"record": self.partner} + with patch( + "odoo.addons.ai_connection.models.ai_connection.AiConnection._run", + return_value=("This is a mocked response", 10, 5, 1), + ): + action._run_action_ai_oca(eval_context) + self.assertEqual(eval_context["ai_result"], "This is a mocked response") + + def test_action_store_variable_without_variable(self): + action = self._create_action("store_variable") + eval_context = {"record": self.partner} + with patch( + "odoo.addons.ai_connection.models.ai_connection.AiConnection._run", + return_value=("This is a mocked response", 10, 5, 1), + ): + action._run_action_ai_oca(eval_context) + self.assertNotIn("ai_result", eval_context) diff --git a/ai_server_action/tests/test_connection_ollama.py b/ai_server_action/tests/test_connection_ollama.py deleted file mode 100644 index 93335c27..00000000 --- a/ai_server_action/tests/test_connection_ollama.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright 2026 Dixmit -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from unittest import mock - -from ollama._types import ChatResponse - -from odoo.tests.common import TransactionCase - - -class OllamaClient: - def __init__(self, chat_messages=None): - self.chat_messages = chat_messages or [] - self.current_message = -1 - self.calls = [] - - def chat(self, *args, **kwargs): - self.current_message += 1 - self.calls.append((args, kwargs)) - return ChatResponse.model_validate( - {"message": self.chat_messages[self.current_message]} - ) - - -class TestConnectionOllama(TransactionCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.connection = cls.env["ai.connection"].create( - { - "name": "Ollama Connection", - "url": "http://my_ollama_server:11434", - "model": "my_ollama_model", - "kind": "ollama", - } - ) - cls.action = cls.env["ir.actions.server"].create( - { - "name": "Test Action", - "model_id": cls.env.ref("base.model_res_partner").id, - "state": "ai_oca", - "ai_connection_id": cls.connection.id, - "ai_tool_ids": [(4, cls.env.ref("ai_tool.current_date").id)], - "ai_prompt": "What is the current date?", - } - ) - cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) - - def test_execute_action_with_tools(self): - client = OllamaClient( - [ - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "function": { - "name": "get_date", - "arguments": {}, - }, - "id": "1", - } - ], - }, - { - "role": "assistant", - "content": "Thanks it is 2024-01-01", - }, - ] - ) - with mock.patch("ollama.Client", return_value=client): - messages = self.partner.message_ids - self.action.with_context( - active_id=self.partner.id, active_model="res.partner" - ).run() - self.assertEqual(client.current_message, 1) - self.assertEqual(messages, self.partner.message_ids) - - def test_execute_action_post_message(self): - client = OllamaClient( - [ - { - "role": "assistant", - "content": "Thanks it is 2024-01-01", - }, - ] - ) - self.action.ai_result_action = "post_message" - with mock.patch("ollama.Client", return_value=client): - messages = self.partner.message_ids - self.action.with_context( - active_id=self.partner.id, active_model="res.partner" - ).run() - self.assertEqual(client.current_message, 0) - self.assertEqual(len(messages) + 1, len(self.partner.message_ids)) - self.assertRegex( - (self.partner.message_ids - messages).body, r".*2024-01-01.*" - ) - - def test_execute_action_field(self): - client = OllamaClient( - [ - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "function": { - "name": "get_date", - "arguments": {}, - }, - "id": "1", - } - ], - }, - { - "role": "assistant", - "content": "Thanks it is 2024-01-01", - }, - ] - ) - self.action.ai_result_action = "update_record" - self.action.ai_update_record_field_id = self.env.ref( - "base.field_res_partner__comment" - ) - self.partner.comment = "Initial Comment" - with mock.patch("ollama.Client", return_value=client): - messages = self.partner.message_ids - self.action.with_context( - active_id=self.partner.id, active_model="res.partner" - ).run() - self.assertEqual(client.current_message, 1) - self.assertEqual(messages, self.partner.message_ids) - self.assertRegex(self.partner.comment, r".*2024-01-01.*") diff --git a/ai_server_action/views/ai_connection.xml b/ai_server_action/views/ai_connection.xml deleted file mode 100644 index bfb4b8be..00000000 --- a/ai_server_action/views/ai_connection.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - ai.connection - -

-
- - - - - - - - - - - - - - - - - - - - ai.connection - - - - - - - - - ai.connection - - - - - - - - - - AI Connection - ai.connection - tree,form - [] - {} - - - - AI Connection - - - - - diff --git a/ai_server_action/views/ir_actions_server.xml b/ai_server_action/views/ir_actions_server.xml index 11836ff3..a67cb1e2 100644 --- a/ai_server_action/views/ir_actions_server.xml +++ b/ai_server_action/views/ir_actions_server.xml @@ -2,14 +2,15 @@ - + ir.actions.server - - + @@ -18,20 +19,23 @@ widget="many2many_tags" domain="['|', ('model_id', '=', model_id), ('kind', 'in', ['generic', 'generic_model'])]" /> - + - - + +