From 8ab81a97d15113ffbdc1659267c7806551a036c7 Mon Sep 17 00:00:00 2001 From: Enric Tobella Date: Mon, 1 Jun 2026 19:06:50 +0200 Subject: [PATCH 1/7] [ADD] ai_connection --- ai_connection/README.rst | 88 ++++ ai_connection/__init__.py | 2 + ai_connection/__manifest__.py | 19 + ai_connection/client.py | 19 + ai_connection/models/__init__.py | 1 + ai_connection/models/ai_connection.py | 118 ++++++ ai_connection/pyproject.toml | 3 + ai_connection/readme/CONTEXT.md | 3 + ai_connection/readme/CONTRIBUTORS.md | 2 + ai_connection/readme/DESCRIPTION.md | 3 + ai_connection/security/ir.model.access.csv | 3 + ai_connection/static/description/icon.png | Bin 0 -> 9455 bytes ai_connection/static/description/index.html | 435 ++++++++++++++++++++ ai_connection/tests/__init__.py | 1 + ai_connection/tests/fake_models.py | 50 +++ ai_connection/tests/test_connection.py | 78 ++++ ai_connection/views/ai_connection.xml | 54 +++ 17 files changed, 879 insertions(+) create mode 100644 ai_connection/README.rst create mode 100644 ai_connection/__init__.py create mode 100644 ai_connection/__manifest__.py create mode 100644 ai_connection/client.py create mode 100644 ai_connection/models/__init__.py create mode 100644 ai_connection/models/ai_connection.py create mode 100644 ai_connection/pyproject.toml create mode 100644 ai_connection/readme/CONTEXT.md create mode 100644 ai_connection/readme/CONTRIBUTORS.md create mode 100644 ai_connection/readme/DESCRIPTION.md create mode 100644 ai_connection/security/ir.model.access.csv create mode 100644 ai_connection/static/description/icon.png create mode 100644 ai_connection/static/description/index.html create mode 100644 ai_connection/tests/__init__.py create mode 100644 ai_connection/tests/fake_models.py create mode 100644 ai_connection/tests/test_connection.py create mode 100644 ai_connection/views/ai_connection.xml diff --git a/ai_connection/README.rst b/ai_connection/README.rst new file mode 100644 index 00000000..3ae55caf --- /dev/null +++ b/ai_connection/README.rst @@ -0,0 +1,88 @@ +============= +Ai Connection +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:55879f38672930678ed61631886590084592fb3717aea06b117f3b816e35e826 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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/18.0/ai_connection + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-18-0/ai-18-0-ai_connection + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This is a module that defines the basic structure of AI Connections. + +However, it does not include any extra configurations. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This module allows to create basic configuration of AI connections. It +is left for child modules the specific connection, however, it creates a +basic structure that will be used from it. Check on existent modules to +know how to handle it. + +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_connection/__init__.py b/ai_connection/__init__.py new file mode 100644 index 00000000..177c8ec6 --- /dev/null +++ b/ai_connection/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .client import AiConnectionClient diff --git a/ai_connection/__manifest__.py b/ai_connection/__manifest__.py new file mode 100644 index 00000000..9ff1a51e --- /dev/null +++ b/ai_connection/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Ai Connection", + "summary": """Creates connections to AI systems""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Dixmit,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/ai", + "depends": [ + "ai_tool", + ], + "data": [ + "security/ir.model.access.csv", + "views/ai_connection.xml", + ], + "demo": [], +} diff --git a/ai_connection/client.py b/ai_connection/client.py new file mode 100644 index 00000000..72657081 --- /dev/null +++ b/ai_connection/client.py @@ -0,0 +1,19 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +class AiConnectionClient: + def handle_message(self, messages=None, **kwargs): + """Handle a message from the AI system. + Will return a dict with the following information: + { + "message": "", + "tool_calls": [ + { + "name": "", + "arguments": {}, + } + ] + } + """ + raise NotImplementedError("Subclasses must implement this method") diff --git a/ai_connection/models/__init__.py b/ai_connection/models/__init__.py new file mode 100644 index 00000000..9f35a7aa --- /dev/null +++ b/ai_connection/models/__init__.py @@ -0,0 +1 @@ +from . import ai_connection diff --git a/ai_connection/models/ai_connection.py b/ai_connection/models/ai_connection.py new file mode 100644 index 00000000..3c129d45 --- /dev/null +++ b/ai_connection/models/ai_connection.py @@ -0,0 +1,118 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +import json + +from odoo import fields, models +from odoo.exceptions import UserError + + +class AiConnection(models.Model): + _name = "ai.connection" + _description = "AI Connection" + _max_iterations = 50 + + name = fields.Char(required=True) + kind = fields.Selection([], required=True) + active = fields.Boolean(default=True) + url = fields.Char(groups="base.group_system") + model = fields.Char(groups="base.group_system") + temperature = fields.Float(default=0.8) + + def _run( + self, + prompt=None, + tools=None, + record=None, + system_prompt=None, + messages=None, + max_iterations=None, + attachments=None, + ): + if messages is None: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + if prompt or attachments: + message = {"role": "user", "content": prompt or ""} + if attachments: + message["files"] = [ + { + "name": attachment.name, + "content": attachment.datas.decode("utf-8"), + "mimetype": attachment.mimetype, + } + for attachment in attachments + ] + messages.append(message) + return self._run_ai( + messages=messages, tools=tools, record=record, max_iterations=max_iterations + ) + + def _run_ai(self, messages, tools=None, record=None, max_iterations=None): + client = getattr(self, f"_get_client_{self.kind}")(tools) + # Shallow copying messages to avoid edition of the messages + messages = list(messages) + if max_iterations is None: + max_iterations = self._max_iterations + iteration = 0 + prompt_tokens = 0 + completion_tokens = 0 + while iteration < max_iterations: + iteration += 1 + response = client.handle_message( + messages=messages, temperature=self.temperature + ) + messages.append(response["message"]) + prompt_tokens += response.get("usage", {}).get("prompt_tokens", 0) + completion_tokens += response.get("usage", {}).get("completion_tokens", 0) + if not response.get("tool_calls"): + return ( + response["message"]["content"], + prompt_tokens, + completion_tokens, + iteration, + ) + for tool_call in response["tool_calls"]: + tool = tools.filtered( + lambda t, tool_call=tool_call: t.name == tool_call["name"] + ) + if tool: + try: + with self.env.cr.savepoint(): + messages.append( + self._process_tool_call(tool, tool_call, record) + ) + except Exception as e: + getattr( + self, + f"_process_tool_call_result_{self.kind}", + self._process_tool_call_result, + )( + tool, + { + "error": str(e), + "type": type(e).__name__, + }, + tool_call, + ) + raise UserError( + self.env._("Iterations reached the maximum allowed (%s)", max_iterations) + ) + + def _process_tool_call(self, tool, tool_call, record): + tool_response = tool._execute_tool(**tool_call["arguments"], record=record) + return getattr( + self, + f"_process_tool_call_result_{self.kind}", + self._process_tool_call_result, + )(tool, tool_response, tool_call) + + def _process_tool_call_result(self, tool, tool_response, tool_call): + return { + "role": "tool", + "name": tool.name, + "tool_call_id": tool_call.get("id"), + "content": json.dumps(tool_response), + } diff --git a/ai_connection/pyproject.toml b/ai_connection/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_connection/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_connection/readme/CONTEXT.md b/ai_connection/readme/CONTEXT.md new file mode 100644 index 00000000..40b6d147 --- /dev/null +++ b/ai_connection/readme/CONTEXT.md @@ -0,0 +1,3 @@ +This module allows to create basic configuration of AI connections. +It is left for child modules the specific connection, however, it creates a basic structure that will be used from it. +Check on existent modules to know how to handle it. diff --git a/ai_connection/readme/CONTRIBUTORS.md b/ai_connection/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2c066ba7 --- /dev/null +++ b/ai_connection/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Dixmit](https://www.dixmit.com) + - Enric Tobella diff --git a/ai_connection/readme/DESCRIPTION.md b/ai_connection/readme/DESCRIPTION.md new file mode 100644 index 00000000..255f5ce6 --- /dev/null +++ b/ai_connection/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This is a module that defines the basic structure of AI Connections. + +However, it does not include any extra configurations. diff --git a/ai_connection/security/ir.model.access.csv b/ai_connection/security/ir.model.access.csv new file mode 100644 index 00000000..9d762bd9 --- /dev/null +++ b/ai_connection/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_connection/static/description/icon.png b/ai_connection/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_connection/static/description/index.html b/ai_connection/static/description/index.html new file mode 100644 index 00000000..a9995ca8 --- /dev/null +++ b/ai_connection/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +Ai Connection + + + +
+

Ai Connection

+ + +

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

+

This is a module that defines the basic structure of AI Connections.

+

However, it does not include any extra configurations.

+

Table of contents

+ +
+

Use Cases / Context

+

This module allows to create basic configuration of AI connections. It +is left for child modules the specific connection, however, it creates a +basic structure that will be used from it. Check on existent modules to +know how to handle it.

+
+
+

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_connection/tests/__init__.py b/ai_connection/tests/__init__.py new file mode 100644 index 00000000..dab0e049 --- /dev/null +++ b/ai_connection/tests/__init__.py @@ -0,0 +1 @@ +from . import test_connection diff --git a/ai_connection/tests/fake_models.py b/ai_connection/tests/fake_models.py new file mode 100644 index 00000000..b1c725d7 --- /dev/null +++ b/ai_connection/tests/fake_models.py @@ -0,0 +1,50 @@ +import base64 + +from odoo import fields, models + +from odoo.addons.ai_connection.client import AiConnectionClient + + +class AiClientDemo(AiConnectionClient): + def __init__(self, tools): + super().__init__() + self.tools = tools or [] + + def handle_message(self, messages, **kwargs): + last_message = messages[-1] + content = last_message["content"] + if last_message.get("files"): + content = base64.b64decode(last_message["files"][0]["content"]).decode( + "utf-8" + ) + if any(tool.name == content for tool in self.tools): + return { + "message": { + "role": "assistant", + "content": f"This is a demo response to the prompt: {content}", + }, + "tool_calls": [ + { + "name": content, + "arguments": {}, + } + ], + } + return { + "message": { + "role": "assistant", + "content": "This is a demo response to the prompt: " f"{content}", + }, + } + + +class AiConnection(models.Model): + _inherit = "ai.connection" + + kind = fields.Selection( + selection_add=[("demo", "Demo")], + ondelete={"demo": "cascade"}, + ) + + def _get_client_demo(self, tools): + return AiClientDemo(tools) diff --git a/ai_connection/tests/test_connection.py b/ai_connection/tests/test_connection.py new file mode 100644 index 00000000..3dcc9eb3 --- /dev/null +++ b/ai_connection/tests/test_connection.py @@ -0,0 +1,78 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time +from odoo_test_helper import FakeModelLoader + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestConnection(TransactionCase): + def setUp(self): + super().setUp() + self.loader = FakeModelLoader(self.env, self.__module__) + self.loader.backup_registry() + from .fake_models import AiConnection + + self.loader.update_registry((AiConnection,)) + self.addCleanup(self.loader.restore_registry) + + def test_demo_connection(self): + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + response = connection._run("Hello, AI!") + self.assertEqual( + response[0], "This is a demo response to the prompt: Hello, AI!" + ) + self.assertEqual(response[3], 1) + + def test_demo_connection_with_attachment(self): + attachment = self.env["ir.attachment"].create( + { + "name": "test.txt", + "datas": "SGVsbG8sIEFJIQ==", # Base64 for "Hello, AI!" + "mimetype": "text/plain", + } + ) + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + response = connection._run(attachments=attachment) + self.assertEqual( + response[0], "This is a demo response to the prompt: Hello, AI!" + ) + self.assertEqual(response[3], 1) + + def test_demo_connection_with_tool(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + with freeze_time("2024-01-01"): + response = connection._run("get_date", tools=tool) + self.assertEqual( + response[0], 'This is a demo response to the prompt: {"date": "2024-01-01"}' + ) + self.assertEqual(response[3], 2) + + def test_demo_connection_max_iterations(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + with self.assertRaises(UserError): + connection._run("get_date", tools=tool, max_iterations=1) diff --git a/ai_connection/views/ai_connection.xml b/ai_connection/views/ai_connection.xml new file mode 100644 index 00000000..08086828 --- /dev/null +++ b/ai_connection/views/ai_connection.xml @@ -0,0 +1,54 @@ + + + + + ai.connection + +
+
+ + + + + + + + + + + + + ai.connection + + + + + + + + + ai.connection + + + + + + + + + + AI Connection + ai.connection + list,form + [] + {} + + + + AI Connection + + + + + From 8f03a8545c45b59cdd6a527a3da92e401a8a1ad8 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Thu, 9 Jul 2026 07:10:45 +0000 Subject: [PATCH 2/7] [UPD] Update ai_connection.pot --- ai_connection/i18n/ai_connection.pot | 87 ++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 ai_connection/i18n/ai_connection.pot diff --git a/ai_connection/i18n/ai_connection.pot b/ai_connection/i18n/ai_connection.pot new file mode 100644 index 00000000..e4224472 --- /dev/null +++ b/ai_connection/i18n/ai_connection.pot @@ -0,0 +1,87 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_connection +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: ai_connection +#: model:ir.actions.act_window,name:ai_connection.ai_connection_act_window +#: model:ir.model,name:ai_connection.model_ai_connection +#: model:ir.ui.menu,name:ai_connection.ai_connection_menu +msgid "AI Connection" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__active +msgid "Active" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__create_date +msgid "Created on" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__id +msgid "ID" +msgstr "" + +#. module: ai_connection +#. odoo-python +#: code:addons/ai_connection/models/ai_connection.py:0 +msgid "Iterations reached the maximum allowed (%s)" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__kind +msgid "Kind" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__model +msgid "Model" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__name +msgid "Name" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__temperature +msgid "Temperature" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__url +msgid "Url" +msgstr "" From fcddd1daaf9f8b74b7b6a5ebde1300ef1b3ddaea Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 9 Jul 2026 07:19:39 +0000 Subject: [PATCH 3/7] [BOT] post-merge updates --- ai_connection/README.rst | 8 ++++-- ai_connection/static/description/index.html | 28 +++++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/ai_connection/README.rst b/ai_connection/README.rst index 3ae55caf..d27012b7 100644 --- a/ai_connection/README.rst +++ b/ai_connection/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ============= Ai Connection ============= @@ -7,13 +11,13 @@ Ai Connection !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:55879f38672930678ed61631886590084592fb3717aea06b117f3b816e35e826 + !! source digest: sha256:c84ab8e901c6c171d4470eaa67cd2b00f27be03cd7e9c6fc177ea9942856bcfd !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |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/licence-AGPL--3-blue.png +.. |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 diff --git a/ai_connection/static/description/index.html b/ai_connection/static/description/index.html index a9995ca8..330dee6e 100644 --- a/ai_connection/static/description/index.html +++ b/ai_connection/static/description/index.html @@ -3,7 +3,7 @@ -Ai Connection +README.rst -
-

Ai Connection

+
+ + +Odoo Community Association + +
+

Ai Connection

-

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 is a module that defines the basic structure of AI Connections.

However, it does not include any extra configurations.

Table of contents

@@ -386,14 +391,14 @@

Ai Connection

-

Use Cases / Context

+

Use Cases / Context

This module allows to create basic configuration of AI connections. It is left for child modules the specific connection, however, it creates a basic structure that will be used from it. Check on existent modules to know how to handle it.

-

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 @@ -401,15 +406,15 @@

Bug Tracker

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 @@ -431,5 +436,6 @@

Maintainers

+
From 887366bf1c348745f055995e0534130f367d94e9 Mon Sep 17 00:00:00 2001 From: mymage Date: Mon, 13 Jul 2026 14:51:37 +0000 Subject: [PATCH 4/7] Added translation using Weblate (Italian) --- ai_connection/i18n/it.po | 88 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 ai_connection/i18n/it.po diff --git a/ai_connection/i18n/it.po b/ai_connection/i18n/it.po new file mode 100644 index 00000000..b092a912 --- /dev/null +++ b/ai_connection/i18n/it.po @@ -0,0 +1,88 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_connection +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: ai_connection +#: model:ir.actions.act_window,name:ai_connection.ai_connection_act_window +#: model:ir.model,name:ai_connection.model_ai_connection +#: model:ir.ui.menu,name:ai_connection.ai_connection_menu +msgid "AI Connection" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__active +msgid "Active" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__create_date +msgid "Created on" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__id +msgid "ID" +msgstr "" + +#. module: ai_connection +#. odoo-python +#: code:addons/ai_connection/models/ai_connection.py:0 +msgid "Iterations reached the maximum allowed (%s)" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__kind +msgid "Kind" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__model +msgid "Model" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__name +msgid "Name" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__temperature +msgid "Temperature" +msgstr "" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__url +msgid "Url" +msgstr "" From c108872d43457a2afba2b97ac3fc24c26dfcd78b Mon Sep 17 00:00:00 2001 From: mymage Date: Mon, 20 Jul 2026 06:48:33 +0000 Subject: [PATCH 5/7] Translated using Weblate (Italian) Currently translated at 100.0% (14 of 14 strings) Translation: ai-18.0/ai-18.0-ai_connection Translate-URL: https://translation.odoo-community.org/projects/ai-18-0/ai-18-0-ai_connection/it/ --- ai_connection/i18n/it.po | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/ai_connection/i18n/it.po b/ai_connection/i18n/it.po index b092a912..4966ca9b 100644 --- a/ai_connection/i18n/it.po +++ b/ai_connection/i18n/it.po @@ -6,83 +6,85 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" -"Last-Translator: Automatically generated\n" +"PO-Revision-Date: 2026-07-20 09:46+0000\n" +"Last-Translator: mymage \n" "Language-Team: none\n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" "Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.15.2\n" #. module: ai_connection #: model:ir.actions.act_window,name:ai_connection.ai_connection_act_window #: model:ir.model,name:ai_connection.model_ai_connection #: model:ir.ui.menu,name:ai_connection.ai_connection_menu msgid "AI Connection" -msgstr "" +msgstr "Connessione IA" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__active msgid "Active" -msgstr "" +msgstr "Attiva" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__create_uid msgid "Created by" -msgstr "" +msgstr "Creato da" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__create_date msgid "Created on" -msgstr "" +msgstr "Creato il" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__display_name msgid "Display Name" -msgstr "" +msgstr "Nome visualizzato" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__id msgid "ID" -msgstr "" +msgstr "ID" #. module: ai_connection #. odoo-python #: code:addons/ai_connection/models/ai_connection.py:0 msgid "Iterations reached the maximum allowed (%s)" -msgstr "" +msgstr "Le iterazioni hanno raggiunto il massimo consentito (%s)" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__kind msgid "Kind" -msgstr "" +msgstr "Genere" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__write_uid msgid "Last Updated by" -msgstr "" +msgstr "Ultimo aggiornamento di" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__write_date msgid "Last Updated on" -msgstr "" +msgstr "Ultimo aggiornamento il" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__model msgid "Model" -msgstr "" +msgstr "Modello" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__name msgid "Name" -msgstr "" +msgstr "Nome" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__temperature msgid "Temperature" -msgstr "" +msgstr "Temperatura" #. module: ai_connection #: model:ir.model.fields,field_description:ai_connection.field_ai_connection__url msgid "Url" -msgstr "" +msgstr "URL" From 4cb84ade2e53972e7ed928babea62dc9c362ab56 Mon Sep 17 00:00:00 2001 From: Volkan TASCI Date: Thu, 15 Oct 2026 19:44:43 +0300 Subject: [PATCH 6/7] [MIG] ai_connection: Migration to 19.0 --- ai_connection/README.rst | 10 ++-- ai_connection/__manifest__.py | 2 +- ai_connection/static/description/index.html | 8 +-- ai_connection/tests/fake_models.py | 5 +- ai_connection/tests/test_connection.py | 62 ++++++++++++++++++--- 5 files changed, 68 insertions(+), 19 deletions(-) diff --git a/ai_connection/README.rst b/ai_connection/README.rst index d27012b7..b1c640e2 100644 --- a/ai_connection/README.rst +++ b/ai_connection/README.rst @@ -21,13 +21,13 @@ Ai Connection :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/18.0/ai_connection + :target: https://github.com/OCA/ai/tree/19.0/ai_connection :alt: OCA/ai .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/ai-18-0/ai-18-0-ai_connection + :target: https://translation.odoo-community.org/projects/ai-19-0/ai-19-0-ai_connection :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=18.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/ai&target_branch=19.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -55,7 +55,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. @@ -87,6 +87,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_connection/__manifest__.py b/ai_connection/__manifest__.py index 9ff1a51e..cb42291d 100644 --- a/ai_connection/__manifest__.py +++ b/ai_connection/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Ai Connection", "summary": """Creates connections to AI systems""", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "license": "AGPL-3", "author": "Dixmit,Odoo Community Association (OCA)", "website": "https://github.com/OCA/ai", diff --git a/ai_connection/static/description/index.html b/ai_connection/static/description/index.html index 330dee6e..29b90fb5 100644 --- a/ai_connection/static/description/index.html +++ b/ai_connection/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Ai Connection + + +
+ + + +Odoo Community Association + +
+

AI Document Extraction

+ +

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

+

This module extracts structured invoice data (partner, invoice number, +payment reference, date, currency, lines and amounts) from uploaded PDF +or image documents by rendering all document pages and sending them +through a configured ai.connection.

+

The extracted data is applied to draft customer invoices and vendor +bills (account.move) either automatically when importing/uploading +an attachment on a journal with an AI connection configured, or on +demand via the Extract with AI button. If the partner name cannot be +matched automatically, a review wizard lets the user select or create +the partner.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Use Cases / Context

+

Accounting teams receive invoices in many formats. Reading them manually +is slow and error prone. This module automates the initial data-entry +step while keeping a human in the loop: the extraction is applied to a +draft move that a user reviews and posts. All AI components run +on-premises (Ollama), so document data never leaves the local +infrastructure when a local endpoint is used.

+
+
+

Configuration

+
    +
  1. Create or configure an AI Connection under Settings > Technical > +AI > AI Connections using your preferred vision-capable AI +connection provider.
  2. +
  3. Link the AI connection either:
      +
    • Per journal: in Accounting > Configuration > Journals, open a +purchase or sales journal and set AI Document Extraction +Connection under the Advanced Settings tab.
    • +
    • Company-wide: in Accounting > Configuration > Settings, under the +AI Document Extraction section, select the default AI +Connection and adjust the Partner Match Threshold (default: +85).
    • +
    +
  4. +
+
+
+

Usage

+
    +
  1. Go to Accounting > Vendors > Bills (or Customers > Invoices).
  2. +
  3. Upload a single-page or multi-page PDF or image invoice, or attach it +to a draft invoice and click Extract with AI.
  4. +
  5. When extraction finishes, AI Extraction State becomes Done and +the partner, invoice date, reference, currency, and invoice lines are +populated.
  6. +
  7. If the partner could not be matched automatically, click Review +Extraction to select the partner and apply it to the draft move.
  8. +
+
+
+

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

+
    +
  • VSL
  • +
+
+ +
+

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_document_extraction/tests/__init__.py b/ai_document_extraction/tests/__init__.py new file mode 100644 index 00000000..3b9649cc --- /dev/null +++ b/ai_document_extraction/tests/__init__.py @@ -0,0 +1 @@ +from . import test_extraction diff --git a/ai_document_extraction/tests/fake_models.py b/ai_document_extraction/tests/fake_models.py new file mode 100644 index 00000000..1e4100d3 --- /dev/null +++ b/ai_document_extraction/tests/fake_models.py @@ -0,0 +1,47 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from odoo.addons.ai_connection.client import AiConnectionClient + + +class FakeExtractionClient(AiConnectionClient): + last_messages = None + response_queue = None + + def __init__(self, tools, default_response=None): + self.tools = tools + self.default_response = default_response or "{}" + + def handle_message(self, messages=None, **kwargs): + FakeExtractionClient.last_messages = list(messages or []) + if FakeExtractionClient.response_queue: + content = FakeExtractionClient.response_queue.pop(0) + if isinstance(content, Exception): + raise content + else: + content = self.default_response + return { + "message": { + "role": "assistant", + "content": content, + }, + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + }, + } + + +class AiConnection(models.Model): + _inherit = "ai.connection" + + kind = fields.Selection( + selection_add=[("fake_extraction", "Fake Extraction")], + ondelete={"fake_extraction": "cascade"}, + ) + fake_response = fields.Text() + + def _get_client_fake_extraction(self, tools): + return FakeExtractionClient(tools, default_response=self.fake_response) diff --git a/ai_document_extraction/tests/test_extraction.py b/ai_document_extraction/tests/test_extraction.py new file mode 100644 index 00000000..df5aedd8 --- /dev/null +++ b/ai_document_extraction/tests/test_extraction.py @@ -0,0 +1,325 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import io +import json +from unittest.mock import patch + +from PIL import Image + +from odoo.exceptions import UserError +from odoo.orm.model_classes import add_to_registry +from odoo.tests.common import TransactionCase + +from ..services import llm_extractor +from .fake_models import AiConnection, FakeExtractionClient + + +def _make_png_bytes(width=200, height=100, color=(255, 255, 255)): + buffer = io.BytesIO() + Image.new("RGB", (width, height), color=color).save(buffer, format="PNG") + return buffer.getvalue() + + +class TestAiDocumentExtraction(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + orig_bases = cls.registry[AiConnection._name]._base_classes__ + add_to_registry(cls.registry, AiConnection) + cls.registry._setup_models__(cls.env.cr, [AiConnection._name]) + cls.registry.init_models( + cls.env.cr, [AiConnection._name], {"models_to_check": True} + ) + + def cleanup_registry(): + model_cls = cls.registry[AiConnection._name] + model_cls._base_classes__ = orig_bases + model_cls._setup_done__ = False + cls.registry._setup_models__(cls.env.cr, [AiConnection._name]) + + cls.addClassCleanup(cleanup_registry) + cls.partner = cls.env["res.partner"].create( + { + "name": "Acme Industrial Supplies GmbH", + "is_company": True, + } + ) + cls.journal = cls.env["account.journal"].search( + [ + ("type", "=", "purchase"), + ("company_id", "=", cls.env.company.id), + ], + limit=1, + ) + cls.tax_20 = cls.env["account.tax"].create( + { + "name": "Purchase VAT 20%", + "amount": 20.0, + "amount_type": "percent", + "type_tax_use": "purchase", + "company_id": cls.env.company.id, + } + ) + cls.connection = cls.env["ai.connection"].create( + { + "name": "Test Vision Connection", + "kind": "fake_extraction", + "fake_response": json.dumps( + { + "partner_name": "Acme Industrial Supplies GmbH", + "invoice_number": "INV-2026-0042", + "payment_reference": "PAY-998877", + "invoice_date": "2026-05-15", + "amount_untaxed": 100.0, + "amount_tax": 20.0, + "amount_total": 120.0, + "currency": cls.env.company.currency_id.name, + "description": "Server hosting services", + "lines": [ + { + "name": "Dedicated Server", + "quantity": 2, + "price_unit": 50.0, + "tax_rate": 20.0, + } + ], + } + ), + } + ) + + def setUp(self): + super().setUp() + FakeExtractionClient.last_messages = None + FakeExtractionClient.response_queue = None + self.journal.ai_connection_id = False + self.env.company.ai_connection_id = False + self.env.company.ai_fuzzy_match_threshold = 85 + + def _create_draft_bill(self, with_attachment=True, filename="invoice.png"): + move = self.env["account.move"].create( + { + "move_type": "in_invoice", + "journal_id": self.journal.id, + } + ) + attachment = None + if with_attachment: + attachment = self.env["ir.attachment"].create( + { + "name": filename, + "datas": base64.b64encode(_make_png_bytes(1600, 1200)), + "mimetype": "image/png", + "res_model": "account.move", + "res_id": move.id, + } + ) + return move, attachment + + def test_manual_extraction_and_review_wizard_flow(self): + """Test manual extraction on a draft bill and review wizard.""" + self.env.company.ai_connection_id = self.connection + move, _attachment = self._create_draft_bill(filename="invoice.png (1)") + move.action_extract_with_ai() + self.assertEqual(move.ai_extraction_state, "done") + self.assertEqual(move.partner_id, self.partner) + self.assertEqual(move.ref, "INV-2026-0042") + self.assertEqual(move.payment_reference, "PAY-998877") + self.assertEqual(str(move.invoice_date), "2026-05-15") + self.assertEqual(move.ai_extracted_tax, 20.0) + self.assertEqual(move.ai_extracted_total, 120.0) + self.assertEqual(len(move.invoice_line_ids), 1) + line = move.invoice_line_ids[0] + self.assertEqual(line.name, "Dedicated Server") + self.assertEqual(line.quantity, 2.0) + self.assertEqual(line.price_unit, 50.0) + self.assertIn(self.tax_20, line.tax_ids) + files = FakeExtractionClient.last_messages[-1]["files"] + self.assertEqual(len(files), 1) + self.assertEqual(files[0]["mimetype"], "image/png") + action = move.action_review_extraction() + wizard = self.env["extraction.wizard"].browse(action["res_id"]) + self.assertEqual(wizard.extracted_partner_name, "Acme Industrial Supplies GmbH") + other_partner = self.env["res.partner"].create( + {"name": "Alternative Vendor", "is_company": True} + ) + wizard.partner_id = other_partner + wizard.action_apply() + self.assertEqual(move.partner_id, other_partner) + + def test_extend_with_attachments_and_multipage_pdf_flow(self): + """Test _extend_with_attachments and multi-page PDF rendering.""" + self.journal.ai_connection_id = self.connection + move, attachment = self._create_draft_bill(filename="multipage.pdf") + attachment.mimetype = "application/pdf" + page_images = [ + Image.new("RGB", (800, 1000), color=(255, 255, 255)), + Image.new("RGB", (800, 1000), color=(240, 240, 240)), + Image.new("RGB", (800, 1000), color=(220, 220, 220)), + ] + with patch( + "odoo.addons.ai_document_extraction.models.account_move.convert_from_bytes", + return_value=page_images, + ): + extended = move._extend_with_attachments( + [ + { + "name": "multipage.pdf", + "raw": None, + "import_file_type": "pdf", + "attachment": attachment, + } + ], + new=True, + ) + self.assertTrue(extended) + self.assertEqual(move.ai_extraction_state, "done") + files = FakeExtractionClient.last_messages[-1]["files"] + self.assertEqual(len(files), 3) + self.assertEqual( + [f["name"] for f in files], + [ + "multipage_page_1.png", + "multipage_page_2.png", + "multipage_page_3.png", + ], + ) + + def test_retry_and_error_handling_flow(self): + """Test retry on invalid LLM output, fallback fields, and error logging.""" + self.env.company.ai_connection_id = self.connection + eur = self.env.ref("base.EUR") + usd = self.env.ref("base.USD") + eur.active = True + usd.active = True + foreign_currency = usd if self.env.company.currency_id == eur else eur + FakeExtractionClient.response_queue = [ + "not valid json at all", + json.dumps( + { + "partner_name": "Unknown SingleWordVendor", + "invoice_number": None, + "payment_reference": "TX-777", + "invoice_date": None, + "amount_untaxed": "75,50", + "amount_tax": "0", + "amount_total": "75,50", + "currency": foreign_currency.name, + "description": "Consulting fee", + "lines": [], + } + ), + ] + move, _attachment = self._create_draft_bill() + move.action_extract_with_ai() + self.assertEqual(move.ai_extraction_state, "done") + self.assertEqual(move.ref, "TX-777") + self.assertEqual(move.currency_id, foreign_currency) + self.assertEqual(len(move.invoice_line_ids), 1) + self.assertEqual(move.invoice_line_ids[0].price_unit, 75.5) + FakeExtractionClient.response_queue = [ + "bad json 1", + "bad json 2", + ] + with self.assertLogs( + "odoo.addons.ai_document_extraction.models.account_move", + level="WARNING", + ): + move.action_extract_with_ai() + self.assertEqual(move.ai_extraction_state, "error") + + def test_validation_guards_and_parser(self): + """Test action guards, settings inheritance, and LLM validation helpers.""" + move, _attachment = self._create_draft_bill(with_attachment=False) + with self.assertRaises(UserError): + move.action_extract_with_ai() + move_with_att, _att = self._create_draft_bill(with_attachment=True) + with self.assertRaises(UserError): + move_with_att.action_extract_with_ai() + entry = self.env["account.move"].create({"move_type": "entry"}) + with self.assertRaises(UserError): + entry.action_extract_with_ai() + settings = self.env["res.config.settings"].create( + { + "ai_connection_id": self.connection.id, + "ai_fuzzy_match_threshold": 90, + } + ) + settings.execute() + self.assertEqual(self.env.company.ai_connection_id, self.connection) + self.assertEqual(self.env.company.ai_fuzzy_match_threshold, 90) + parsed = llm_extractor.parse_and_validate( + 'Prefix {broken} {"partner_name": "qwen3-vl", ' + '"invoice_number": "http://bad", ' + '"payment_reference": "REF-1", "invoice_date": "1990-01-01", ' + '"currency": "INVALID", ' + '"lines": ["skip", {"name": "Item", "tax_id": "bad", "tax_rate": "bad"}]}', + available_taxes=[ + {"id": 1, "name": "VAT", "amount": 20.0, "amount_type": "percent"} + ], + available_currencies=["EUR", "USD"], + ) + self.assertIsNone(parsed["partner_name"]) + self.assertIsNone(parsed["invoice_number"]) + self.assertEqual(parsed["payment_reference"], "REF-1") + self.assertIsNone(parsed["invoice_date"]) + self.assertIsNone(parsed["currency"]) + self.assertNotIn("tax_id", parsed["lines"][1]) + self.assertNotIn("tax_rate", parsed["lines"][1]) + + # Additional parser & helper edge cases + parsed_non_list = llm_extractor.parse_and_validate( + '{"invoice_date": "bad-date", "lines": "not-a-list"}' + ) + self.assertEqual(parsed_non_list["lines"], []) + self.assertIsNone(parsed_non_list["invoice_date"]) + + self.env.company.ai_connection_id = False + self.assertFalse(move._ai_connection(raise_if_missing=False)) + self.assertIsNone(move._ai_to_float(True)) + self.assertIsNone(move._ai_to_float("abc")) + self.assertIsNone(move._ai_to_float(None)) + self.assertIsNone(move._match_partner("", 85)) + self.assertEqual(move._ai_resolve_tax(tax_id=self.tax_20.id), self.tax_20) + self.assertFalse(move._ai_resolve_tax(tax_id="bad", tax_rate="bad")) + move._ai_set_lines([{"name": "Empty", "quantity": None, "price_unit": None}]) + move._apply_extraction({"invoice_date": "bad-date"}) + + # _extend_with_attachments edge cases (empty, non-processable, no connection) + self.assertFalse(move._extend_with_attachments([])) + self.assertFalse( + move._extend_with_attachments( + [{"name": "notes.txt", "raw": None, "import_file_type": "txt"}] + ) + ) + self.assertTrue( + move_with_att._extend_with_attachments( + [{"name": "invoice.png", "raw": None, "import_file_type": "png"}] + ) + ) + + # Fallback mimetype, broken raw_extraction in review, non-draft apply + _att.mimetype = "application/octet-stream" + self.assertEqual(move_with_att._ai_get_attachment(), _att) + move_with_att.ai_raw_extraction = "{not-json" + action = move_with_att.action_review_extraction() + wizard = self.env["extraction.wizard"].browse(action["res_id"]) + with patch.object(type(move_with_att), "state", "posted"): + with self.assertRaises(UserError): + move_with_att.action_extract_with_ai() + close_act = wizard.action_apply() + self.assertEqual(close_act["type"], "ir.actions.act_window_close") + + # Empty attachment & empty PDF pages errors + with patch( + "odoo.addons.ai_document_extraction.models.account_move.convert_from_bytes", + return_value=[], + ): + _att.name = "empty.pdf" + with self.assertRaises(UserError): + move_with_att._ai_prepare_attachments(_att) + empty_att = self.env["ir.attachment"].new({"name": "empty.png"}) + with self.assertRaises(UserError): + move_with_att._ai_prepare_attachments(empty_att) diff --git a/ai_document_extraction/views/account_journal_views.xml b/ai_document_extraction/views/account_journal_views.xml new file mode 100644 index 00000000..3f85777b --- /dev/null +++ b/ai_document_extraction/views/account_journal_views.xml @@ -0,0 +1,21 @@ + + + + + account.journal.form.ai.extraction + account.journal + + + + + + + + diff --git a/ai_document_extraction/views/account_move_views.xml b/ai_document_extraction/views/account_move_views.xml new file mode 100644 index 00000000..8c6fc8ef --- /dev/null +++ b/ai_document_extraction/views/account_move_views.xml @@ -0,0 +1,67 @@ + + + + + account.move.form.ai.extraction + account.move + + + +