diff --git a/ai_connection/README.rst b/ai_connection/README.rst new file mode 100644 index 00000000..b1c640e2 --- /dev/null +++ b/ai_connection/README.rst @@ -0,0 +1,92 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============= +Ai Connection +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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/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/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-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=19.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..cb42291d --- /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": "19.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/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 "" diff --git a/ai_connection/i18n/it.po b/ai_connection/i18n/it.po new file mode 100644 index 00000000..4966ca9b --- /dev/null +++ b/ai_connection/i18n/it.po @@ -0,0 +1,90 @@ +# 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" +"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 "Connessione IA" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__active +msgid "Active" +msgstr "Attiva" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__id +msgid "ID" +msgstr "ID" + +#. module: ai_connection +#. odoo-python +#: code:addons/ai_connection/models/ai_connection.py:0 +msgid "Iterations reached the maximum allowed (%s)" +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 "Genere" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__write_uid +msgid "Last Updated by" +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 "Ultimo aggiornamento il" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__model +msgid "Model" +msgstr "Modello" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__name +msgid "Name" +msgstr "Nome" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__temperature +msgid "Temperature" +msgstr "Temperatura" + +#. module: ai_connection +#: model:ir.model.fields,field_description:ai_connection.field_ai_connection__url +msgid "Url" +msgstr "URL" 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 00000000..3a0328b5 Binary files /dev/null and b/ai_connection/static/description/icon.png differ diff --git a/ai_connection/static/description/index.html b/ai_connection/static/description/index.html new file mode 100644 index 00000000..29b90fb5 --- /dev/null +++ b/ai_connection/static/description/index.html @@ -0,0 +1,441 @@ + + + + + +Ai Connection + + + +
+ + + +Odoo Community Association + +
+

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..988dab3e --- /dev/null +++ b/ai_connection/tests/fake_models.py @@ -0,0 +1,53 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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": f"This is a demo response to the prompt: {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..b2ab4a48 --- /dev/null +++ b/ai_connection/tests/test_connection.py @@ -0,0 +1,124 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from odoo.exceptions import UserError +from odoo.orm.model_classes import add_to_registry +from odoo.tests.common import TransactionCase + +from odoo.addons.ai_connection.client import AiConnectionClient + +from .fake_models import AiConnection + + +class TestConnection(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) + + def test_base_client_not_implemented(self): + with self.assertRaises(NotImplementedError): + AiConnectionClient().handle_message() + + 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) + + def test_demo_connection_system_prompt(self): + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + response = connection._run( + "Hello, AI!", system_prompt="You are a demo assistant" + ) + self.assertEqual( + response[0], "This is a demo response to the prompt: Hello, AI!" + ) + self.assertEqual(response[3], 1) + + def test_demo_connection_with_failing_tool(self): + tool = self.env.ref("ai_tool.post_message") + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + # post_message is a generic_model tool: without a record it raises, + # exercising the tool-call error handling in _run_ai. + response = connection._run("post_message", tools=tool) + self.assertIn("post_message", response[0]) + self.assertEqual(response[3], 2) 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 + + + + + diff --git a/ai_document_extraction/README.rst b/ai_document_extraction/README.rst new file mode 100644 index 00000000..169a9662 --- /dev/null +++ b/ai_document_extraction/README.rst @@ -0,0 +1,133 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +====================== +AI Document Extraction +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:668da0bb9a58a0c06761b3a2ec36dba6550f629e9520ec657a958e094e39af8f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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/19.0/ai_document_extraction + :alt: OCA/ai +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/ai-19-0/ai-19-0-ai_document_extraction + :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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +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** + +.. contents:: + :local: + +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. 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``). + +Usage +===== + +1. Go to *Accounting > Vendors > Bills* (or *Customers > Invoices*). +2. Upload a single-page or multi-page PDF or image invoice, or attach it + to a draft invoice and click **Extract with AI**. +3. When extraction finishes, *AI Extraction State* becomes **Done** and + the partner, invoice date, reference, currency, and invoice lines are + populated. +4. If the partner could not be matched automatically, click **Review + Extraction** to select the partner and apply it to the draft move. + +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 + +Contributors +------------ + +- VSL info@voslo.co + +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_document_extraction/__init__.py b/ai_document_extraction/__init__.py new file mode 100644 index 00000000..07e5f1a7 --- /dev/null +++ b/ai_document_extraction/__init__.py @@ -0,0 +1 @@ +from . import models, services, wizards diff --git a/ai_document_extraction/__manifest__.py b/ai_document_extraction/__manifest__.py new file mode 100644 index 00000000..63b5bc7e --- /dev/null +++ b/ai_document_extraction/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "AI Document Extraction", + "summary": "Extract invoice data from PDFs and images using an AI connection", + "version": "19.0.1.0.0", + "category": "Accounting/Accounting", + "website": "https://github.com/OCA/ai", + "author": "VSL, Odoo Community Association (OCA)", + "license": "AGPL-3", + "development_status": "Alpha", + "depends": ["account", "ai_connection"], + "external_dependencies": { + "python": ["Pillow", "pdf2image", "rapidfuzz"], + }, + "data": [ + "security/ir.model.access.csv", + "views/account_journal_views.xml", + "views/account_move_views.xml", + "views/res_config_settings_views.xml", + "wizards/extraction_wizard_views.xml", + ], + "demo": [], +} diff --git a/ai_document_extraction/i18n/ai_document_extraction.pot b/ai_document_extraction/i18n/ai_document_extraction.pot new file mode 100644 index 00000000..fbcc1696 --- /dev/null +++ b/ai_document_extraction/i18n/ai_document_extraction.pot @@ -0,0 +1,278 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_document_extraction +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.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_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_journal__ai_connection_id +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_company__ai_connection_id +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_config_settings__ai_connection_id +msgid "AI Connection" +msgstr "" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.res_config_settings_view_form_ai_document_extraction +msgid "AI Document Extraction" +msgstr "" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.account_move_form_ai_extraction +msgid "AI Extraction" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_bank_statement_line__ai_extraction_state +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__ai_extraction_state +msgid "AI Extraction State" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_bank_statement_line__ai_raw_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__ai_raw_extraction +msgid "AI Raw Extraction" +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction completed." +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction failed: %s" +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction is only available on draft moves." +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction is only available on invoices." +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,help:ai_document_extraction.field_account_journal__ai_connection_id +msgid "" +"AI connection used to extract invoice data on this journal. If empty, the " +"company default AI connection is used." +msgstr "" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.extraction_wizard_view_form +msgid "Apply" +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "Attach the invoice PDF or image to the chatter first." +msgstr "" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.extraction_wizard_view_form +msgid "Cancel" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model,name:ai_document_extraction.model_res_company +msgid "Companies" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model,name:ai_document_extraction.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,help:ai_document_extraction.field_res_company__ai_document_extraction_connection_id +#: model:ir.model.fields,help:ai_document_extraction.field_res_config_settings__ai_document_extraction_connection_id +msgid "" +"Default AI connection used to extract invoice data from PDFs and images when " +"none is set on the journal." +msgstr "" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.res_config_settings_view_form_ai_document_extraction +msgid "" +"Default AI connection and fuzzy partner matching threshold for invoice " +"extraction." +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_journal__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_company__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_config_settings__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields.selection,name:ai_document_extraction.selection__account_move__ai_extraction_state__done +msgid "Done" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields.selection,name:ai_document_extraction.selection__account_move__ai_extraction_state__error +msgid "Error" +msgstr "" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.account_move_form_ai_extraction +msgid "Extract with AI" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__extracted_partner_name +msgid "Extracted Partner Name" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_bank_statement_line__ai_extracted_tax +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__ai_extracted_tax +msgid "Extracted Tax Amount" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_bank_statement_line__ai_extracted_total +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__ai_extracted_total +msgid "Extracted Total Amount" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_journal__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_company__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_config_settings__id +msgid "ID" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__move_id +msgid "Invoice" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model,name:ai_document_extraction.model_account_journal +msgid "Journal" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model,name:ai_document_extraction.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,help:ai_document_extraction.field_res_company__ai_document_extraction_fuzzy_threshold +#: model:ir.model.fields,help:ai_document_extraction.field_res_config_settings__ai_document_extraction_fuzzy_threshold +msgid "" +"Minimum similarity percentage (0-100) required to auto-match the extracted " +"partner name with an existing partner." +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "" +"No AI Connection is configured for document extraction. Configure one on the " +"journal or in Accounting Settings." +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "No invoice/receipt number found; payment reference was used." +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields.selection,name:ai_document_extraction.selection__account_move__ai_extraction_state__draft +msgid "Not processed" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__partner_id +msgid "Partner" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_company__ai_fuzzy_match_threshold +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_config_settings__ai_fuzzy_match_threshold +msgid "Partner Match Threshold" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields.selection,name:ai_document_extraction.selection__account_move__ai_extraction_state__processing +msgid "Processing" +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +#: model:ir.model,name:ai_document_extraction.model_extraction_wizard +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.extraction_wizard_view_form +msgid "Review AI Extraction" +msgstr "" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.account_move_form_ai_extraction +msgid "Review Extraction" +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "The PDF could not be rendered." +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "" +"The invoice date could not be extracted; please review the draft before " +"posting." +msgstr "" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "" +"The uploaded file is ready for AI extraction. Use 'Extract with AI' to fill " +"the invoice." +msgstr "" diff --git a/ai_document_extraction/i18n/tr.po b/ai_document_extraction/i18n/tr.po new file mode 100644 index 00000000..1ed68bc1 --- /dev/null +++ b/ai_document_extraction/i18n/tr.po @@ -0,0 +1,295 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * ai_document_extraction +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-13 00:00+0000\n" +"PO-Revision-Date: 2026-08-13 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: Turkish\n" +"Language: tr_TR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_journal__ai_connection_id +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_company__ai_connection_id +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_config_settings__ai_connection_id +msgid "AI Connection" +msgstr "AI Bağlantısı" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.res_config_settings_view_form_ai_document_extraction +msgid "AI Document Extraction" +msgstr "AI Belge Çıkarma" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.account_move_form_ai_extraction +msgid "AI Extraction" +msgstr "AI Çıkarma" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_bank_statement_line__ai_extraction_state +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__ai_extraction_state +msgid "AI Extraction State" +msgstr "AI Çıkarma Durumu" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_bank_statement_line__ai_raw_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__ai_raw_extraction +msgid "AI Raw Extraction" +msgstr "AI Ham Çıkarma" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction completed." +msgstr "AI çıkarma işlemi tamamlandı." + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction failed: %s" +msgstr "AI çıkarma işlemi başarısız: %s" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction is only available on draft moves." +msgstr "AI çıkarma yalnızca taslak hareketlerde kullanılabilir." + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction is only available on invoices." +msgstr "AI çıkarma yalnızca faturalarda kullanılabilir." + +#. module: ai_document_extraction +#: model:ir.model.fields,help:ai_document_extraction.field_account_journal__ai_connection_id +msgid "" +"AI connection used to extract invoice data on this journal. If empty, the " +"company default AI connection is used." +msgstr "" +"Bu yevmiyede fatura verilerini çıkarmak için kullanılan AI bağlantısı. Boş " +"bırakılırsa şirketin varsayılan AI bağlantısı kullanılır." + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.extraction_wizard_view_form +msgid "Apply" +msgstr "Uygula" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "Attach the invoice PDF or image to the chatter first." +msgstr "Önce fatura PDF'sini veya görselini konuşmaya ekleyin." + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.extraction_wizard_view_form +msgid "Cancel" +msgstr "İptal" + +#. module: ai_document_extraction +#: model:ir.model,name:ai_document_extraction.model_res_company +msgid "Companies" +msgstr "Şirketler" + +#. module: ai_document_extraction +#: model:ir.model,name:ai_document_extraction.model_res_config_settings +msgid "Config Settings" +msgstr "Yapılandırma Ayarları" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__create_uid +msgid "Created by" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__create_date +msgid "Created on" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,help:ai_document_extraction.field_res_company__ai_document_extraction_connection_id +#: model:ir.model.fields,help:ai_document_extraction.field_res_config_settings__ai_document_extraction_connection_id +msgid "" +"Default AI connection used to extract invoice data from PDFs and images when " +"none is set on the journal." +msgstr "" +"Yevmiyede tanımlı olmadığında PDF ve görsellerden fatura verilerini çıkarmak " +"için kullanılan varsayılan AI bağlantısı." + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.res_config_settings_view_form_ai_document_extraction +msgid "" +"Default AI connection and fuzzy partner matching threshold for invoice " +"extraction." +msgstr "" +"Fatura çıkarma için varsayılan AI bağlantısı ve bulanık iş ortağı eşleştirme " +"eşiği." + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_journal__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_company__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_config_settings__display_name +msgid "Display Name" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields.selection,name:ai_document_extraction.selection__account_move__ai_extraction_state__done +msgid "Done" +msgstr "Tamamlandı" + +#. module: ai_document_extraction +#: model:ir.model.fields.selection,name:ai_document_extraction.selection__account_move__ai_extraction_state__error +msgid "Error" +msgstr "Hata" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.account_move_form_ai_extraction +msgid "Extract with AI" +msgstr "AI ile Çıkar" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__extracted_partner_name +msgid "Extracted Partner Name" +msgstr "Çıkarılan İş Ortağı Adı" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_bank_statement_line__ai_extracted_tax +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__ai_extracted_tax +msgid "Extracted Tax Amount" +msgstr "Çıkarılan Vergi Tutarı" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_bank_statement_line__ai_extracted_total +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__ai_extracted_total +msgid "Extracted Total Amount" +msgstr "Çıkarılan Toplam Tutar" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_journal__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_company__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_config_settings__id +msgid "ID" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__move_id +msgid "Invoice" +msgstr "Fatura" + +#. module: ai_document_extraction +#: model:ir.model,name:ai_document_extraction.model_account_journal +msgid "Journal" +msgstr "Yevmiye" + +#. module: ai_document_extraction +#: model:ir.model,name:ai_document_extraction.model_account_move +msgid "Journal Entry" +msgstr "Yevmiye Kaydı" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__write_date +msgid "Last Updated on" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,help:ai_document_extraction.field_res_company__ai_document_extraction_fuzzy_threshold +#: model:ir.model.fields,help:ai_document_extraction.field_res_config_settings__ai_document_extraction_fuzzy_threshold +msgid "" +"Minimum similarity percentage (0-100) required to auto-match the extracted " +"partner name with an existing partner." +msgstr "" +"Çıkarılan iş ortağı adının mevcut bir iş ortağıyla otomatik eşleşmesi için " +"gereken minimum benzerlik yüzdesi (0-100)." + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "" +"No AI Connection is configured for document extraction. Configure one on the " +"journal or in Accounting Settings." +msgstr "" +"Belge çıkarma için yapılandırılmış bir AI Bağlantısı yok. Yevmiye üzerinde " +"veya Muhasebe Ayarlarında bir bağlantı tanımlayın." + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "No invoice/receipt number found; payment reference was used." +msgstr "Fatura/makbuz numarası bulunamadı; ödeme referansı kullanıldı." + +#. module: ai_document_extraction +#: model:ir.model.fields.selection,name:ai_document_extraction.selection__account_move__ai_extraction_state__draft +msgid "Not processed" +msgstr "İşlenmedi" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__partner_id +msgid "Partner" +msgstr "İş Ortağı" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_company__ai_fuzzy_match_threshold +#: model:ir.model.fields,field_description:ai_document_extraction.field_res_config_settings__ai_fuzzy_match_threshold +msgid "Partner Match Threshold" +msgstr "İş Ortağı Eşleştirme Eşiği" + +#. module: ai_document_extraction +#: model:ir.model.fields.selection,name:ai_document_extraction.selection__account_move__ai_extraction_state__processing +msgid "Processing" +msgstr "İşleniyor" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +#: model:ir.model,name:ai_document_extraction.model_extraction_wizard +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.extraction_wizard_view_form +msgid "Review AI Extraction" +msgstr "AI Çıkarmasını İncele" + +#. module: ai_document_extraction +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.account_move_form_ai_extraction +msgid "Review Extraction" +msgstr "Çıkarmayı İncele" + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "The PDF could not be rendered." +msgstr "PDF işlenemedi." + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "" +"The invoice date could not be extracted; please review the draft before " +"posting." +msgstr "" +"Fatura tarihi çıkarılamadı; lütfen deftere nakletmeden önce taslağı " +"inceleyin." + +#. module: ai_document_extraction +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "" +"The uploaded file is ready for AI extraction. Use 'Extract with AI' to fill " +"the invoice." +msgstr "" +"Yüklenen dosya AI çıkarmaya hazır. Faturayı doldurmak için 'AI ile Çıkar'ı " +"kullanın." diff --git a/ai_document_extraction/models/__init__.py b/ai_document_extraction/models/__init__.py new file mode 100644 index 00000000..3c226794 --- /dev/null +++ b/ai_document_extraction/models/__init__.py @@ -0,0 +1 @@ +from . import account_journal, account_move, res_company, res_config_settings diff --git a/ai_document_extraction/models/account_journal.py b/ai_document_extraction/models/account_journal.py new file mode 100644 index 00000000..f002be65 --- /dev/null +++ b/ai_document_extraction/models/account_journal.py @@ -0,0 +1,15 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + ai_connection_id = fields.Many2one( + "ai.connection", + string="AI Document Extraction Connection", + help="AI connection used to extract invoice data on this journal. " + "Falls back to the company AI connection when empty.", + ) diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py new file mode 100644 index 00000000..01b40fe8 --- /dev/null +++ b/ai_document_extraction/models/account_move.py @@ -0,0 +1,457 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import io +import json +import logging +import os +import re + +from pdf2image import convert_from_bytes +from PIL import Image +from rapidfuzz import fuzz + +from odoo import fields, models +from odoo.exceptions import UserError + +from ..services import llm_extractor + +_logger = logging.getLogger(__name__) + +_IMAGE_EXTENSIONS = ("pdf", "png", "jpg", "jpeg", "gif", "bmp") + + +class AccountMove(models.Model): + _inherit = "account.move" + + ai_extraction_state = fields.Selection( + [ + ("draft", "Not processed"), + ("processing", "Processing"), + ("done", "Done"), + ("error", "Error"), + ], + string="AI Extraction State", + default="draft", + copy=False, + ) + ai_raw_extraction = fields.Text( + string="AI Raw Extraction", + copy=False, + readonly=True, + ) + ai_extracted_tax = fields.Float( + string="Extracted Tax Amount", + copy=False, + readonly=True, + ) + ai_extracted_total = fields.Float( + string="Extracted Total Amount", + copy=False, + readonly=True, + ) + + def _ai_connection(self, raise_if_missing=True): + self.ensure_one() + connection = ( + self.journal_id.ai_connection_id or self.company_id.ai_connection_id + ) + if not connection.exists(): + if raise_if_missing: + raise UserError( + self.env._( + "No AI Connection is configured for document extraction. " + "Configure one on the journal or in Accounting Settings." + ) + ) + return self.env["ai.connection"] + return connection.sudo() + + def _ai_to_float(self, value): + """Coerce an extracted amount to float, or None if not numeric.""" + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.replace(",", ".").strip()) + except ValueError: + return None + return None + + def _ai_get_attachment(self): + self.ensure_one() + attachments = self.env["ir.attachment"].search( + [ + ("res_model", "=", "account.move"), + ("res_id", "=", self.id), + ], + order="create_date desc, id desc", + ) + for attachment in attachments: + if attachment.mimetype and attachment.mimetype.split("/")[-1] in ( + _IMAGE_EXTENSIONS + ): + return attachment + if self._ai_file_extension(attachment.name) in _IMAGE_EXTENSIONS: + return attachment + return None + + def _ai_file_extension(self, filename): + """Return the lower-cased file extension of ``filename``.""" + name = re.sub(r"\s+\(\d+\)$", "", filename or "") + return name.rsplit(".", 1)[-1].lower() + + def _ai_is_processable(self, filename): + """Whether the uploaded file can be handled by the AI extraction.""" + return self._ai_file_extension(filename) in _IMAGE_EXTENSIONS + + def _ai_find_processable_attachment(self, files_data): + for file_data in files_data: + if not self._ai_is_processable(file_data.get("name")): + continue + attachment = file_data.get("attachment") + if attachment: + return attachment + return self._ai_get_attachment() + + def _extend_with_attachments(self, files_data, new=False): + if not files_data: + return False + result = super()._extend_with_attachments(files_data, new=new) + if result: + return result + if not all( + self._ai_is_processable(file_data.get("name")) for file_data in files_data + ): + return result + connection = self._ai_connection(raise_if_missing=False) + attachment = self._ai_find_processable_attachment(files_data) + if connection and attachment: + self._extract_with_ai(attachment, connection=connection) + return True + self.message_post( + body=self.env._( + "The uploaded file is ready for AI extraction. " + "Use 'Extract with AI' to fill the invoice." + ) + ) + return True + + def action_extract_with_ai(self): + self.ensure_one() + if self.state != "draft": + raise UserError( + self.env._("AI extraction is only available on draft moves.") + ) + if self.move_type not in ( + "in_invoice", + "in_receipt", + "out_invoice", + "out_receipt", + ): + raise UserError(self.env._("AI extraction is only available on invoices.")) + attachment = self._ai_get_attachment() + if not attachment: + raise UserError( + self.env._("Attach the invoice PDF or image to the chatter first.") + ) + connection = self._ai_connection(raise_if_missing=True) + self._extract_with_ai(attachment, connection=connection) + return True + + def action_review_extraction(self): + self.ensure_one() + partner_name = None + if self.ai_raw_extraction: + try: + data = json.loads(self.ai_raw_extraction) + partner_name = data.get("partner_name") + except (ValueError, TypeError): + _logger.debug( + "Could not parse stored AI extraction for move %s", + self.id, + exc_info=True, + ) + wizard = self.env["extraction.wizard"].create( + { + "move_id": self.id, + "extracted_partner_name": partner_name or "", + "partner_id": self.partner_id.id, + } + ) + return { + "name": self.env._("Review AI Extraction"), + "type": "ir.actions.act_window", + "res_model": "extraction.wizard", + "res_id": wizard.id, + "view_mode": "form", + "target": "new", + } + + def _ai_resize_image_bytes(self, image, max_dimension=1280): + """Convert PIL image to RGB, cap dimensions, and return PNG bytes.""" + rgb_image = image.convert("RGB") + width, height = rgb_image.size + if max(width, height) > max_dimension: + ratio = max_dimension / max(width, height) + rgb_image = rgb_image.resize( + ( + max(1, round(width * ratio)), + max(1, round(height * ratio)), + ) + ) + buffer = io.BytesIO() + rgb_image.save(buffer, format="PNG") + return buffer.getvalue() + + def _ai_prepare_attachments(self, attachment): + """Render all PDF pages or image into resized PNG ir.attachment records.""" + self.ensure_one() + data = attachment.with_context(bin_size=False).raw + if not data: + raise UserError(self.env._("The attachment is empty.")) + extension = self._ai_file_extension(attachment.name) + if extension == "pdf" or (attachment.mimetype and "pdf" in attachment.mimetype): + images = convert_from_bytes(data, dpi=200) + if not images: + raise UserError(self.env._("The PDF could not be rendered.")) + else: + with Image.open(io.BytesIO(data)) as image: + images = [image.copy()] + base_name = os.path.splitext(attachment.name or "document")[0] + prepared = self.env["ir.attachment"] + for index, page_image in enumerate(images, start=1): + png_bytes = self._ai_resize_image_bytes(page_image) + prepared |= self.env["ir.attachment"].new( + { + "name": f"{base_name}_page_{index}.png", + "datas": base64.b64encode(png_bytes), + "mimetype": "image/png", + } + ) + return prepared + + def _ai_extract_data(self, connection, prepared_attachments): + """Run the vision LLM and parse the result, retrying once on failure.""" + prompt = llm_extractor._build_user_prompt( + self._ai_available_taxes(), self._ai_available_currencies() + ) + last_error = None + for _attempt in range(2): + try: + content = connection._run( + prompt=prompt, + system_prompt=llm_extractor.SYSTEM_PROMPT, + record=self, + attachments=prepared_attachments, + )[0] + return llm_extractor.parse_and_validate( + content, + available_taxes=self._ai_available_taxes(), + available_currencies=self._ai_available_currencies(), + ) + except ValueError as error: + last_error = error + raise last_error + + def _match_partner(self, name, threshold): + if not name: + return None + partners = self.env["res.partner"].search( + [ + ("is_company", "=", True), + "|", + ("company_id", "=", False), + ("company_id", "=", self.company_id.id), + ], + limit=1000, + ) + best, best_score = None, 0 + for partner in partners: + score = fuzz.token_sort_ratio(name, partner.name or "") + if score > best_score: + best, best_score = partner, score + if best and best_score >= threshold: + return best + return None + + def _ai_get_line_account(self): + self.ensure_one() + group = ( + "income" + if self.move_type in ("out_invoice", "out_receipt", "out_refund") + else "expense" + ) + account = self.env["account.account"].search( + [ + ("internal_group", "=", group), + ("company_ids", "in", self.company_id.id), + ], + limit=1, + ) + if not account: + account = self.env["account.account"].search( + [("company_ids", "in", self.company_id.id)], limit=1 + ) + return account + + def _ai_available_taxes(self): + """Taxes the LLM may assign to invoice lines, keyed for the prompt.""" + self.ensure_one() + use = ( + "sale" + if self.move_type in ("out_invoice", "out_receipt", "out_refund") + else "purchase" + ) + taxes = self.env["account.tax"].search( + [ + ("type_tax_use", "=", use), + ("amount_type", "!=", "group"), + ("amount", ">=", 0.0), + ("active", "=", True), + "|", + ("company_id", "=", self.company_id.id), + ("company_id", "=", False), + ] + ) + return [ + { + "id": tax.id, + "name": tax.name, + "amount": tax.amount, + "amount_type": tax.amount_type, + } + for tax in taxes + ] + + def _ai_available_currencies(self): + return self.env["res.currency"].search([("active", "=", True)]).mapped("name") + + def _ai_resolve_tax(self, tax_id=None, tax_rate=None): + """Resolve an LLM tax reference to an account.tax record.""" + self.ensure_one() + available = self._ai_available_taxes() + if tax_id: + try: + tax_id = int(tax_id) + except (ValueError, TypeError): + tax_id = None + for tax in available: + if tax["id"] == tax_id: + return self.env["account.tax"].browse(tax_id) + if tax_rate: + try: + rate = float(tax_rate) + except (ValueError, TypeError): + return self.env["account.tax"] + for tax in available: + if tax["amount_type"] == "percent" and abs(tax["amount"] - rate) < 1e-9: + return self.env["account.tax"].browse(tax["id"]) + return self.env["account.tax"] + + def _ai_set_lines(self, lines, description=None): + self.ensure_one() + account = self._ai_get_line_account() + commands = [(2, line.id) for line in self.line_ids] + for line in lines: + name = (line.get("name") or description or "").strip() + quantity = self._ai_to_float(line.get("quantity")) + price_unit = self._ai_to_float(line.get("price_unit")) + if quantity is None and price_unit is None: + continue + tax = self._ai_resolve_tax(line.get("tax_id"), line.get("tax_rate")) + commands.append( + ( + 0, + 0, + { + "name": name, + "account_id": account.id, + "quantity": quantity if quantity else 1.0, + "price_unit": price_unit if price_unit else 0.0, + "tax_ids": [(6, 0, tax.ids)] if tax else [], + }, + ) + ) + self.line_ids = commands + + def _apply_extraction(self, data): + self.ensure_one() + values = {} + invoice_date = data.get("invoice_date") + if invoice_date: + try: + values["invoice_date"] = fields.Date.to_date(invoice_date) + except ValueError: + _logger.debug( + "Invalid invoice_date extracted for move %s: %s", + self.id, + invoice_date, + ) + else: + self.message_post( + body=self.env._( + "The invoice date could not be extracted; " + "please review the draft before posting." + ) + ) + invoice_number = data.get("invoice_number") + payment_reference = data.get("payment_reference") + if invoice_number: + values["ref"] = invoice_number + elif payment_reference: + values["ref"] = payment_reference + self.message_post( + body=self.env._( + "No invoice/receipt number found; payment reference was used." + ) + ) + if payment_reference: + values["payment_reference"] = payment_reference + partner = None + threshold = self.company_id.ai_fuzzy_match_threshold or 85 + if data.get("partner_name"): + partner = self._match_partner(data["partner_name"], threshold) + if partner: + values["partner_id"] = partner.id + currency_code = data.get("currency") + if currency_code: + currency = self.env["res.currency"].search( + [("name", "=", str(currency_code).strip().upper())], limit=1 + ) + if currency and currency != self.company_id.currency_id: + values["currency_id"] = currency.id + if values: + self.write(values) + lines = data.get("lines") or [] + description = data.get("description") + if lines: + self._ai_set_lines(lines, description=description) + else: + untaxed = self._ai_to_float(data.get("amount_untaxed")) + if untaxed and untaxed > 0: + self._ai_set_lines([{"name": description or "", "price_unit": untaxed}]) + self.ai_extracted_tax = self._ai_to_float(data.get("amount_tax")) or 0.0 + self.ai_extracted_total = self._ai_to_float(data.get("amount_total")) or 0.0 + return partner + + def _extract_with_ai(self, attachment, connection=None): + self.ensure_one() + self.ai_extraction_state = "processing" + try: + connection = connection or self._ai_connection(raise_if_missing=True) + prepared = self._ai_prepare_attachments(attachment) + data = self._ai_extract_data(connection, prepared) + self._apply_extraction(data) + self.ai_raw_extraction = json.dumps(data, indent=2) + self.ai_extraction_state = "done" + self.message_post(body=self.env._("AI extraction completed.")) + except Exception as error: # noqa: BLE001 + self.ai_extraction_state = "error" + _logger.warning( + "AI extraction failed for move %s: %s", self.id, error, exc_info=True + ) + self.message_post(body=self.env._("AI extraction failed: %s", error)) diff --git a/ai_document_extraction/models/res_company.py b/ai_document_extraction/models/res_company.py new file mode 100644 index 00000000..c81d2a5c --- /dev/null +++ b/ai_document_extraction/models/res_company.py @@ -0,0 +1,17 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + ai_connection_id = fields.Many2one( + "ai.connection", + string="AI Document Extraction Connection", + ) + ai_fuzzy_match_threshold = fields.Integer( + string="AI Partner Match Threshold", + default=85, + ) diff --git a/ai_document_extraction/models/res_config_settings.py b/ai_document_extraction/models/res_config_settings.py new file mode 100644 index 00000000..017a7303 --- /dev/null +++ b/ai_document_extraction/models/res_config_settings.py @@ -0,0 +1,17 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + ai_connection_id = fields.Many2one( + related="company_id.ai_connection_id", + readonly=False, + ) + ai_fuzzy_match_threshold = fields.Integer( + related="company_id.ai_fuzzy_match_threshold", + readonly=False, + ) diff --git a/ai_document_extraction/pyproject.toml b/ai_document_extraction/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ai_document_extraction/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ai_document_extraction/readme/CONFIGURE.md b/ai_document_extraction/readme/CONFIGURE.md new file mode 100644 index 00000000..c479c8ac --- /dev/null +++ b/ai_document_extraction/readme/CONFIGURE.md @@ -0,0 +1,9 @@ +1. Create or configure an *AI Connection* under *Settings > Technical > AI > AI + Connections* using your preferred vision-capable AI connection provider. +2. 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`). diff --git a/ai_document_extraction/readme/CONTEXT.md b/ai_document_extraction/readme/CONTEXT.md new file mode 100644 index 00000000..bed71e7e --- /dev/null +++ b/ai_document_extraction/readme/CONTEXT.md @@ -0,0 +1,5 @@ +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. diff --git a/ai_document_extraction/readme/CONTRIBUTORS.md b/ai_document_extraction/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..7a6950c3 --- /dev/null +++ b/ai_document_extraction/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- VSL diff --git a/ai_document_extraction/readme/DESCRIPTION.md b/ai_document_extraction/readme/DESCRIPTION.md new file mode 100644 index 00000000..a4ea6cef --- /dev/null +++ b/ai_document_extraction/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +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. diff --git a/ai_document_extraction/readme/USAGE.md b/ai_document_extraction/readme/USAGE.md new file mode 100644 index 00000000..05b6a36c --- /dev/null +++ b/ai_document_extraction/readme/USAGE.md @@ -0,0 +1,7 @@ +1. Go to *Accounting > Vendors > Bills* (or *Customers > Invoices*). +2. Upload a single-page or multi-page PDF or image invoice, or attach it to a + draft invoice and click **Extract with AI**. +3. When extraction finishes, *AI Extraction State* becomes **Done** and the + partner, invoice date, reference, currency, and invoice lines are populated. +4. If the partner could not be matched automatically, click **Review + Extraction** to select the partner and apply it to the draft move. diff --git a/ai_document_extraction/security/ir.model.access.csv b/ai_document_extraction/security/ir.model.access.csv new file mode 100644 index 00000000..a9f3f86e --- /dev/null +++ b/ai_document_extraction/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_extraction_wizard_manager,access.extraction.wizard.manager,model_extraction_wizard,account.group_account_manager,1,1,1,0 +access_extraction_wizard_invoice,access.extraction.wizard.invoice,model_extraction_wizard,account.group_account_invoice,1,1,1,0 diff --git a/ai_document_extraction/services/__init__.py b/ai_document_extraction/services/__init__.py new file mode 100644 index 00000000..ee5fa0a5 --- /dev/null +++ b/ai_document_extraction/services/__init__.py @@ -0,0 +1 @@ +from . import llm_extractor diff --git a/ai_document_extraction/services/llm_extractor.py b/ai_document_extraction/services/llm_extractor.py new file mode 100644 index 00000000..286aa750 --- /dev/null +++ b/ai_document_extraction/services/llm_extractor.py @@ -0,0 +1,195 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +from datetime import date, datetime + +SYSTEM_PROMPT = ( + "/no_think\n" + "Extract invoice data from the document image(s) as a JSON object. " + "partner_name is the company that issued the invoice (the seller/supplier " + "for a vendor bill, the customer for a customer invoice); never use a " + "logo, slogan or model name. " + "invoice_number: the document's business number exactly as printed - look " + "for a field labeled Invoice, Receipt, Order or Document number; if " + "several exist prefer Invoice, then Receipt, then Order/Document; copy it " + "even when it looks like a long hexadecimal code; never a URL or file " + "path; null if none of those labels exist. " + "payment_reference: the payment or transaction reference - a field labeled " + "Payment number, Transaction ID or Payment ID; null if none. " + "invoice_date as YYYY-MM-DD when a date is visible on the document, else " + "null. " + "amount_untaxed, amount_tax and amount_total read from the document. " + "lines: each visible line item across all pages with name, quantity, " + "price_unit and tax_rate from the provided tax rates (or null when " + "untaxed). " + "currency from the provided currency codes (or null). " + "description: a short summary of what the document is for. " + "Output ONLY the JSON object." +) + +EXPECTED_FIELDS = ( + "partner_name", + "invoice_number", + "payment_reference", + "invoice_date", + "amount_untaxed", + "amount_tax", + "amount_total", + "currency", + "description", + "lines", +) + +_MODEL_NAMES = { + "deepseek", + "qwen", + "qwen2", + "qwen2.5", + "qwen3", + "qwen3-vl", + "llama", + "llama3", + "gpt", + "gpt-4", + "gpt-4o", + "claude", + "gemini", + "mistral", + "mixtral", + "phi", + "deepseek-v4-flash", +} + + +def _build_user_prompt(available_taxes=None, available_currencies=None): + tax_block = "" + if available_taxes: + tax_lines = "\n".join( + f"- {tax['name']} = {tax['amount']}%" for tax in available_taxes + ) + tax_block = ( + "Available tax rates (tax_rate must be one of these numbers):\n" + f"{tax_lines}\n" + ) + currency_block = "" + if available_currencies: + currency_block = ( + f"Available currencies (ISO codes): {', '.join(available_currencies)}\n" + ) + return ( + "Extract the following fields from the invoice image(s) as a single JSON " + "object:\n" + '{"partner_name": , "invoice_number": , ' + '"payment_reference": , ' + '"invoice_date": <"YYYY-MM-DD" or null>, "amount_untaxed": , ' + '"amount_tax": , "amount_total": , ' + '"currency": , "description": , ' + '"lines": [{"name": , "quantity": , ' + '"price_unit": , "tax_rate": }]}\n' + f"{tax_block}" + f"{currency_block}" + "Output ONLY the JSON object, with no markdown or extra text." + ) + + +def _parse_json_response(content): + decoder = json.JSONDecoder() + for position in range(len(content)): + if content[position] == "{": + try: + data, _ = decoder.raw_decode(content, position) + except json.JSONDecodeError: + continue + if not isinstance(data, dict): + raise ValueError("LLM response is not a JSON object") + for field in EXPECTED_FIELDS: + data.setdefault(field, None) + if not isinstance(data.get("lines"), list): + data["lines"] = [] + return data + raise ValueError(f"No JSON object found in LLM response: {content[:200]}") + + +def _validate_reference(data, field): + candidate = str(data.get(field) or "").strip() + if not candidate or "://" in candidate or "%" in candidate or "\\" in candidate: + data[field] = None + + +def _validate_partner_name(data): + candidate = str(data.get("partner_name") or "").strip() + if not candidate or candidate.lower() in _MODEL_NAMES: + data["partner_name"] = None + + +def _validate_invoice_date(data): + raw = data.get("invoice_date") + if not raw: + return + try: + parsed = datetime.strptime(str(raw), "%Y-%m-%d").date() + except (ValueError, TypeError): + data["invoice_date"] = None + return + if not (date(2000, 1, 1) <= parsed <= date.today()): + data["invoice_date"] = None + + +def _validate_currency(data, available_currencies): + currency = data.get("currency") + if currency and available_currencies: + known = {code.strip().upper() for code in available_currencies} + if str(currency).strip().upper() not in known: + data["currency"] = None + + +def _validate_lines(data, available_taxes): + if not available_taxes: + return + available_ids = {int(tax["id"]) for tax in available_taxes} + available_rates = { + float(tax["amount"]) + for tax in available_taxes + if tax.get("amount_type") == "percent" + } + for line in data.get("lines") or []: + if not isinstance(line, dict): + continue + tax_id = line.get("tax_id") + if tax_id is not None: + try: + valid = int(tax_id) in available_ids + except (ValueError, TypeError): + valid = False + if not valid: + line.pop("tax_id", None) + tax_rate = line.get("tax_rate") + if tax_rate is not None: + try: + valid = float(tax_rate) in available_rates + except (ValueError, TypeError): + valid = False + if not valid: + line.pop("tax_rate", None) + + +def _validate_data(data, available_taxes=None, available_currencies=None): + """Drop hallucinated values so only trustworthy fields reach the move.""" + _validate_reference(data, "invoice_number") + _validate_reference(data, "payment_reference") + _validate_partner_name(data) + _validate_invoice_date(data) + _validate_currency(data, available_currencies) + _validate_lines(data, available_taxes) + return data + + +def parse_and_validate(content, available_taxes=None, available_currencies=None): + """Parse raw LLM output into a validated extraction dict.""" + data = _parse_json_response(content) + return _validate_data( + data, + available_taxes=available_taxes, + available_currencies=available_currencies, + ) diff --git a/ai_document_extraction/static/description/icon.png b/ai_document_extraction/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/ai_document_extraction/static/description/icon.png differ diff --git a/ai_document_extraction/static/description/index.html b/ai_document_extraction/static/description/index.html new file mode 100644 index 00000000..732f1c1a --- /dev/null +++ b/ai_document_extraction/static/description/index.html @@ -0,0 +1,487 @@ + + + + + +README.rst + + + +
+ + + +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 + + + +