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..0d18a382 --- /dev/null +++ b/ai_connection/tests/fake_models.py @@ -0,0 +1,54 @@ +# 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): + _name = "ai.connection" + _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..3caa75cb --- /dev/null +++ b/ai_connection/tests/test_connection.py @@ -0,0 +1,78 @@ +# Copyright 2026 Dixmit +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from odoo.exceptions import UserError +from odoo.orm.model_classes import add_to_registry +from odoo.tests.common import TransactionCase + +from .fake_models import AiConnection + + +class TestConnection(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + add_to_registry(cls.registry, AiConnection) + cls.registry._setup_models__(cls.env.cr, [AiConnection._name]) + cls.registry.init_models(cls.env.cr, [AiConnection._name], {}) + + def test_demo_connection(self): + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + response = connection._run("Hello, AI!") + self.assertEqual( + response[0], "This is a demo response to the prompt: Hello, AI!" + ) + self.assertEqual(response[3], 1) + + def test_demo_connection_with_attachment(self): + attachment = self.env["ir.attachment"].create( + { + "name": "test.txt", + "datas": "SGVsbG8sIEFJIQ==", # Base64 for "Hello, AI!" + "mimetype": "text/plain", + } + ) + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + response = connection._run(attachments=attachment) + self.assertEqual( + response[0], "This is a demo response to the prompt: Hello, AI!" + ) + self.assertEqual(response[3], 1) + + def test_demo_connection_with_tool(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + with freeze_time("2024-01-01"): + response = connection._run("get_date", tools=tool) + self.assertEqual( + response[0], 'This is a demo response to the prompt: {"date": "2024-01-01"}' + ) + self.assertEqual(response[3], 2) + + def test_demo_connection_max_iterations(self): + tool = self.env.ref("ai_tool.current_date") + connection = self.env["ai.connection"].create( + { + "name": "Demo Connection", + "kind": "demo", + } + ) + with self.assertRaises(UserError): + connection._run("get_date", tools=tool, max_iterations=1) diff --git a/ai_connection/views/ai_connection.xml b/ai_connection/views/ai_connection.xml new file mode 100644 index 00000000..08086828 --- /dev/null +++ b/ai_connection/views/ai_connection.xml @@ -0,0 +1,54 @@ + + + + + ai.connection + +
+
+ + + + + + + + + + + + + ai.connection + + + + + + + + + ai.connection + + + + + + + + + + AI Connection + ai.connection + list,form + [] + {} + + + + AI Connection + + + + + diff --git a/ai_document_extraction/README.rst b/ai_document_extraction/README.rst new file mode 100644 index 00000000..6a7c86b4 --- /dev/null +++ b/ai_document_extraction/README.rst @@ -0,0 +1,164 @@ +.. 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, +date and amounts) from uploaded PDF, JPG or PNG documents by sending the +rendered document image to an OpenAI-compatible vision LLM (e.g. Ollama +running ``qwen3-vl`` or a cloud provider such as OpenRouter) that +returns a strict JSON payload. + +The result is applied to a draft vendor bill (``account.move``): +partner, date, reference and a single amount line are set automatically. +Processing runs in the background through ``queue_job`` so the user +interface never blocks. If the extracted partner name cannot be matched, +a wizard lets the user pick or create the partner. + +The LLM is instructed to ignore logo/slogan texts found in the document +header (e.g. a company name drawn inside a logo), to never compute +missing values, and to output ``null`` for anything it cannot read. + +.. 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. + +Installation +============ + +To install and run this module you need the following Python packages +(installed with ``pip``): + +- ``Pillow`` +- ``pdf2image`` +- ``rapidfuzz`` +- ``requests`` + +And the system package: + +- ``poppler-utils`` (required by ``pdf2image`` to render PDFs) + +A running OpenAI-compatible chat completions endpoint with a vision +model is required, for example Ollama (``http://ollama:11434/v1``) +running a vision model such as ``qwen3-vl``, or a cloud provider such as +OpenRouter (``https://openrouter.ai/api/v1``). + +Usage +===== + +1. Go to *Accounting > Vendors > Bills* and create a draft vendor bill + (or open an existing draft one). +2. Attach the invoice PDF or image to the chatter. +3. Click **Extract with AI**. The invoice is processed in the + background. +4. When the *AI Extraction State* becomes *Done*, check the extracted + values. The partner is set automatically when a match is found. +5. If the partner could not be matched, click **Review Extraction** and + pick or create the partner in the wizard. + +Configure the AI backend as follows: + +1. Create an *AI Connection* under *Settings > AI > AI Connection* with + kind **OpenAI-compatible**: + + - Set the URL to a cloud provider such as + ``https://openrouter.ai/api/v1`` or to a local Ollama server like + ``http://localhost:11434/v1``. + - Set the model, e.g. ``qwen/qwen3-vl-32b-instruct`` for OpenRouter + or ``qwen3-vl:8b`` for Ollama. + - Set the API key for cloud providers only (leave it empty for local + Ollama) and set the temperature to 0. For Ollama you can also set + the *Ollama Context Window* and *Ollama Keep Alive*. + +2. Link the connection under *Settings > Technical > AI > AI Document + Extraction* in the *AI Connection* field (developer mode is required + to see the Technical menu). The partner match threshold can also be + set there. + +The extraction flow is unchanged: upload the invoice, then click +**Extract with AI**. + +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..7a3c5c04 --- /dev/null +++ b/ai_document_extraction/__manifest__.py @@ -0,0 +1,32 @@ +# 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 a vision LLM", + "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", + "application": False, + "installable": True, + "depends": ["base", "account", "bus", "queue_job", "ai_connection"], + "external_dependencies": { + "python": ["Pillow", "pdf2image", "rapidfuzz", "requests"], + }, + "data": [ + "security/ir.model.access.csv", + "views/account_move_views.xml", + "views/ai_connection_views.xml", + "wizards/ai_document_extraction_settings_views.xml", + "wizards/extraction_wizard_views.xml", + ], + "assets": { + "web.assets_backend": [ + "ai_document_extraction/static/src/account_move_form_ai_extraction.js", + ], + }, + "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..da995495 --- /dev/null +++ b/ai_document_extraction/i18n/ai_document_extraction.pot @@ -0,0 +1,279 @@ +# 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-20260723\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: \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_ai_document_extraction_settings__ai_connection_id +msgid "AI Connection" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.actions.act_window,name:ai_document_extraction.ai_document_extraction_settings_action +#: model:ir.ui.menu,name:ai_document_extraction.ai_document_extraction_settings_menu +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.ai_document_extraction_settings_view_form +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 +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction started." +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.ai_document_extraction_settings_view_form +#: 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_ai_document_extraction_settings +msgid "AI Document Extraction Settings" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,help:ai_document_extraction.field_ai_document_extraction_settings__ai_connection_id +msgid "Connection used to extract invoice data from documents." +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,field_description:ai_document_extraction.field_account_move__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_ai_document_extraction_settings__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__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_move__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_ai_document_extraction_settings__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__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.fields,field_description:ai_document_extraction.field_account_bank_statement_line__invoice_date +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__invoice_date +msgid "Invoice/Bill Date" +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_ai_document_extraction_settings__fuzzy_match_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. Set one in Settings > Technical > AI > AI Document Extraction." +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_ai_connection__num_ctx +msgid "Ollama Context Window" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_ai_connection__keep_alive +msgid "Ollama Keep Alive" +msgstr "" + +#. module: ai_document_extraction +#: model:ir.model.fields,selection:ai_connection.field_ai_connection__kind +msgid "OpenAI-compatible" +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_ai_document_extraction_settings__fuzzy_match_threshold +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.ai_document_extraction_settings_view_form +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 +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.ai_document_extraction_settings_view_form +msgid "Save" +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..72e0ba28 --- /dev/null +++ b/ai_document_extraction/i18n/tr.po @@ -0,0 +1,301 @@ +# 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-20260723\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_ai_document_extraction_settings__ai_connection_id +msgid "AI Connection" +msgstr "AI Bağlantısı" + +#. module: ai_document_extraction +#: model:ir.actions.act_window,name:ai_document_extraction.ai_document_extraction_settings_action +#: model:ir.ui.menu,name:ai_document_extraction.ai_document_extraction_settings_menu +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.ai_document_extraction_settings_view_form +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 +#. odoo-python +#: code:addons/ai_document_extraction/models/account_move.py:0 +msgid "AI extraction started." +msgstr "AI çıkarma işlemi başladı." + +#. 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.ai_document_extraction_settings_view_form +#: 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_ai_document_extraction_settings +msgid "AI Document Extraction Settings" +msgstr "AI Belge Çıkarma Ayarları" + +#. module: ai_document_extraction +#: model:ir.model.fields,help:ai_document_extraction.field_ai_document_extraction_settings__ai_connection_id +msgid "Connection used to extract invoice data from documents." +msgstr "Belgelerden fatura verilerini çıkarmak için kullanılan bağlantı." + +#. 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,field_description:ai_document_extraction.field_account_move__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_ai_document_extraction_settings__display_name +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__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_move__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_ai_document_extraction_settings__id +#: model:ir.model.fields,field_description:ai_document_extraction.field_extraction_wizard__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.fields,field_description:ai_document_extraction.field_account_bank_statement_line__invoice_date +#: model:ir.model.fields,field_description:ai_document_extraction.field_account_move__invoice_date +msgid "Invoice/Bill Date" +msgstr "Fatura/Fiş Tarihi" + +#. 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_ai_document_extraction_settings__fuzzy_match_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. Set one in Settings " +"> Technical > AI > AI Document Extraction." +msgstr "" +"Belge çıkarma için yapılandırılmış bir AI Bağlantısı yok. Ayarlar > Teknik > " +"AI > AI Belge Çıkarma bölümünden bir tane belirleyin." +"Belge çıkarma için yapılandırılmış bir AI Bağlantısı yok. Ayarlar > Genel " +"Ayarlar > AI Belge Çıkarma bölümünden bir tane belirleyin." + +#. 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_ai_connection__num_ctx +msgid "Ollama Context Window" +msgstr "Ollama Bağlam Penceresi" + +#. module: ai_document_extraction +#: model:ir.model.fields,field_description:ai_document_extraction.field_ai_connection__keep_alive +msgid "Ollama Keep Alive" +msgstr "Ollama Canlı Tutma Süresi" + +#. module: ai_document_extraction +#: model:ir.model.fields,selection:ai_connection.field_ai_connection__kind +msgid "OpenAI-compatible" +msgstr "OpenAI uyumlu" + +#. 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_ai_document_extraction_settings__fuzzy_match_threshold +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.ai_document_extraction_settings_view_form +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 +#: model_terms:ir.ui.view,arch_db:ai_document_extraction.ai_document_extraction_settings_view_form +msgid "Save" +msgstr "Kaydet" + +#. 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." + +#~ msgid "" +#~ "Extract invoice data from PDFs and images using a vision LLM. Link an AI " +#~ "Connection (OpenRouter or local Ollama) configured in Settings > AI > AI " +#~ "Connection." +#~ msgstr "" +#~ "PDF ve görsellerden bir görüntü LLM'i ile fatura verilerini çıkarın. " +#~ "Ayarlar > AI > AI Bağlantısı bölümünde yapılandırılmış bir AI Bağlantısı " +#~ "(OpenRouter veya yerel Ollama) bağlayın." diff --git a/ai_document_extraction/models/__init__.py b/ai_document_extraction/models/__init__.py new file mode 100644 index 00000000..d9977930 --- /dev/null +++ b/ai_document_extraction/models/__init__.py @@ -0,0 +1,4 @@ +from . import ( + account_move, + ai_connection, +) diff --git a/ai_document_extraction/models/account_move.py b/ai_document_extraction/models/account_move.py new file mode 100644 index 00000000..ccd13bae --- /dev/null +++ b/ai_document_extraction/models/account_move.py @@ -0,0 +1,577 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import logging +import os +import re +import tempfile + +from odoo import api, 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" + + invoice_date = fields.Date( + string="Invoice/Bill Date", + index=True, + copy=False, + default=lambda self: fields.Date.context_today(self), + ) + + 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, + ) + + @api.model + def _ai_get_param(self, param_name, default=None): + return ( + self.env["ir.config_parameter"] + .sudo() + .get_param(f"ai_document_extraction.{param_name}", default=default) + ) + + def _ai_settings(self): + self.ensure_one() + return { + "ai_connection_id": int(self._ai_get_param("ai_connection_id", "0") or 0), + "fuzzy_match_threshold": int( + self._ai_get_param("fuzzy_match_threshold", "85") + ), + } + + def _ai_connection(self): + self.ensure_one() + connection = self.env["ai.connection"].browse( + self._ai_settings()["ai_connection_id"] + ) + if not connection.exists(): + raise UserError( + self.env._( + "No AI Connection is configured for document extraction. " + "Set one in Settings > Technical > AI > AI Document " + "Extraction." + ) + ) + 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", + ) + 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``. + + Odoo renames duplicate attachments with a " (N)" suffix (e.g. + "invoice.pdf (1)"), so it is stripped before reading the extension. + """ + 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 _extend_with_attachments(self, files_data, new=False): + """Don't show the generic import error for files handled by the AI. + + Odoo posts "There was an error while importing the bill..." whenever no + EDI decoder applies to an uploaded file. Images and PDFs have no EDI + decoder but are perfectly valid for our AI extraction, so treat them as + successfully imported and guide the user to the AI button instead. + """ + result = super()._extend_with_attachments(files_data, new=new) + if ( + not result + and files_data + and all( + self._ai_is_processable(file_data.get("name")) + for file_data in files_data + ) + ): + self.message_post( + body=self.env._( + "The uploaded file is ready for AI extraction. " + "Use 'Extract with AI' to fill the invoice." + ) + ) + return True + return result + + 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.") + ) + self.ai_extraction_state = "processing" + self.message_post(body=self.env._("AI extraction started.")) + self.with_delay()._extract_with_ai_job(attachment.id) + 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(self, path, max_dimension=1280): + """Cap the image size to keep vision-model tokens and latency low.""" + from PIL import Image + + with Image.open(path) as image: + image = image.convert("RGB") + width, height = image.size + if max(width, height) <= max_dimension: + return path + ratio = max_dimension / max(width, height) + image = image.resize( + ( + max(1, round(width * ratio)), + max(1, round(height * ratio)), + ) + ) + resized = f"{path}.resized.png" + image.save(resized, "PNG") + os.unlink(path) + return resized + + def _ai_prepare_image(self, attachment): + data = attachment.with_context(bin_size=False).raw + extension = self._ai_file_extension(attachment.name) + handle, file_path = tempfile.mkstemp(suffix=f".{extension}") + os.close(handle) + try: + with open(file_path, "wb") as file_handle: + file_handle.write(data) + if extension == "pdf": + from pdf2image import convert_from_path + + images = convert_from_path( + file_path, dpi=200, first_page=1, last_page=1 + ) + if not images: + raise UserError(self.env._("The PDF could not be rendered.")) + png_path = f"{file_path}.png" + images[0].save(png_path, "PNG") + os.unlink(file_path) + file_path = png_path + return self._ai_resize_image(file_path) + except Exception: + self._ai_cleanup_tmp(file_path) + raise + + def _ai_mime_from_extension(self, path): + extension = os.path.splitext(path)[1].lower() + return { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".bmp": "image/bmp", + }.get(extension, "image/png") + + def _ai_vision_message(self, image_path, connection): + """Build the user message carrying the (resized) invoice image.""" + import base64 + + is_ollama = "ollama" in (connection.url or "").lower() + if is_ollama: + with open(image_path, "rb") as image_file: + encoded = base64.b64encode(image_file.read()).decode() + mime = self._ai_mime_from_extension(image_path) + else: + import io + + from PIL import Image + + buffer = io.BytesIO() + with Image.open(image_path) as image: + image.convert("RGB").save(buffer, "JPEG", quality=90) + encoded = base64.b64encode(buffer.getvalue()).decode() + mime = "image/jpeg" + return { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{encoded}"}, + }, + { + "type": "text", + "text": llm_extractor._build_user_prompt( + self._ai_available_taxes(), self._ai_available_currencies() + ), + }, + ], + } + + def _ai_extract_data(self, connection, image_path): + """Run the vision LLM and parse the result, retrying once on failure.""" + last_error = None + for _attempt in range(2): + # On an empty response the client already retried once, so a full + # re-run means at most 4 HTTP calls total (accepted, rare). + try: + content = connection._run( + system_prompt=llm_extractor.SYSTEM_PROMPT, + messages=[self._ai_vision_message(image_path, connection)], + )[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 _ai_cleanup_tmp(self, path): + for candidate in (path, f"{path}.png"): + if os.path.exists(candidate): + try: + os.unlink(candidate) + except OSError: + _logger.debug("Could not remove temporary file %s", candidate) + + def _ai_store_processed_image(self, processed_path): + """Store the OCR-ready image as an attachment on the move.""" + self.ensure_one() + if not processed_path or not os.path.exists(processed_path): + return + import base64 + + with open(processed_path, "rb") as image_file: + datas = base64.b64encode(image_file.read()) + self.env["ir.attachment"].create( + { + "name": f"{self.name or 'move'}-ai-processed.png", + "datas": datas, + "mimetype": "image/png", + "res_model": "account.move", + "res_id": self.id, + } + ) + + def _match_partner(self, name, threshold): + if not name: + return None + try: + from rapidfuzz import fuzz + except ImportError: # pragma: no cover + return None + partners = self.env["res.partner"].search( + [("is_company", "=", True)], 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() + if self.move_type in ("out_invoice", "out_receipt", "out_refund"): + account = self.env["account.account"].search( + [ + ("internal_group", "=", "income"), + ("company_ids", "in", self.company_id.id), + ], + limit=1, + ) + else: + account = self.env["account.account"].search( + [ + ("internal_group", "=", "expense"), + ("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. + + Exact match only: by tax id, or by tax rate against the available + percent taxes. Unknown references yield an empty recordset so the + line stays untaxed. + """ + 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 + settings = self._ai_settings() + if data.get("partner_name"): + partner = self._match_partner( + data["partner_name"], settings["fuzzy_match_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_job(self, attachment_id): + self.ensure_one() + attachment = self.env["ir.attachment"].browse(attachment_id) + image_path = None + try: + image_path = self._ai_prepare_image(attachment) + connection = self._ai_connection() + data = self._ai_extract_data(connection, image_path) + self._ai_store_processed_image(image_path) + 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 - job boundary + self.ai_extraction_state = "error" + _logger.error( + "AI extraction failed for move %s: %s", self.id, error, exc_info=True + ) + self.message_post(body=self.env._("AI extraction failed: %s", error)) + finally: + if image_path: + self._ai_cleanup_tmp(image_path) + self._ai_notify_state_change() + + def _ai_notify_state_change(self): + """Notify the web client that the AI extraction state changed.""" + try: + self.env["bus.bus"]._sendone( + f"ai_document_extraction.move.{self.id}", + "ai_document_extraction", + {"move_id": self.id, "state": self.ai_extraction_state}, + ) + except Exception as error: # noqa: BLE001 - notification must not break the job + _logger.warning( + "Could not notify AI extraction state change for move %s: %s", + self.id, + error, + exc_info=True, + ) diff --git a/ai_document_extraction/models/ai_connection.py b/ai_document_extraction/models/ai_connection.py new file mode 100644 index 00000000..6d16e65f --- /dev/null +++ b/ai_document_extraction/models/ai_connection.py @@ -0,0 +1,41 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +from ..services.ai_openai_compatible_client import AiOpenAICompatibleClient + + +class AiConnection(models.Model): + _inherit = "ai.connection" + + kind = fields.Selection( + selection_add=[("openai_compatible", "OpenAI-compatible")], + ondelete={"openai_compatible": "cascade"}, + ) + api_key = fields.Char(groups="base.group_system") + num_ctx = fields.Integer( + string="Ollama Context Window", + groups="base.group_system", + help="Ollama num_ctx, only used when the URL points to an Ollama server.", + ) + keep_alive = fields.Char( + string="Ollama Keep Alive", + groups="base.group_system", + help="Ollama keep_alive duration, e.g. 30m.", + ) + temperature = fields.Float(default=0.0) + + def _get_client_openai_compatible(self, tools): + return AiOpenAICompatibleClient( + url=self.url, + model=self.model, + api_key=self.api_key, + num_ctx=self.num_ctx, + keep_alive=self.keep_alive, + timeout=int( + self.env["ir.config_parameter"] + .sudo() + .get_param("ai_document_extraction.llm_timeout", "300") + ), + ) 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/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..24073728 --- /dev/null +++ b/ai_document_extraction/readme/DESCRIPTION.md @@ -0,0 +1,14 @@ +This module extracts structured invoice data (partner, invoice number, date and +amounts) from uploaded PDF, JPG or PNG documents by sending the rendered document +image to an OpenAI-compatible vision LLM (e.g. Ollama running `qwen3-vl` or a +cloud provider such as OpenRouter) that returns a strict JSON payload. + +The result is applied to a draft vendor bill (`account.move`): partner, date, +reference and a single amount line are set automatically. Processing runs in the +background through `queue_job` so the user interface never blocks. If the +extracted partner name cannot be matched, a wizard lets the user pick or create +the partner. + +The LLM is instructed to ignore logo/slogan texts found in the document header +(e.g. a company name drawn inside a logo), to never compute missing values, and to +output `null` for anything it cannot read. diff --git a/ai_document_extraction/readme/INSTALL.md b/ai_document_extraction/readme/INSTALL.md new file mode 100644 index 00000000..901f6a0d --- /dev/null +++ b/ai_document_extraction/readme/INSTALL.md @@ -0,0 +1,16 @@ +To install and run this module you need the following Python packages +(installed with ``pip``): + +* `Pillow` +* `pdf2image` +* `rapidfuzz` +* `requests` + +And the system package: + +* `poppler-utils` (required by `pdf2image` to render PDFs) + +A running OpenAI-compatible chat completions endpoint with a vision model is +required, for example Ollama (`http://ollama:11434/v1`) running a vision model +such as `qwen3-vl`, or a cloud provider such as OpenRouter +(`https://openrouter.ai/api/v1`). diff --git a/ai_document_extraction/readme/USAGE.md b/ai_document_extraction/readme/USAGE.md new file mode 100644 index 00000000..bf244277 --- /dev/null +++ b/ai_document_extraction/readme/USAGE.md @@ -0,0 +1,26 @@ +1. Go to *Accounting > Vendors > Bills* and create a draft vendor bill (or open an + existing draft one). +2. Attach the invoice PDF or image to the chatter. +3. Click **Extract with AI**. The invoice is processed in the background. +4. When the *AI Extraction State* becomes *Done*, check the extracted values. The + partner is set automatically when a match is found. +5. If the partner could not be matched, click **Review Extraction** and pick or + create the partner in the wizard. + +Configure the AI backend as follows: + +1. Create an *AI Connection* under *Settings > AI > AI Connection* with kind + **OpenAI-compatible**: + - Set the URL to a cloud provider such as `https://openrouter.ai/api/v1` or to + a local Ollama server like `http://localhost:11434/v1`. + - Set the model, e.g. `qwen/qwen3-vl-32b-instruct` for OpenRouter or + `qwen3-vl:8b` for Ollama. + - Set the API key for cloud providers only (leave it empty for local Ollama) + and set the temperature to 0. For Ollama you can also set the *Ollama Context + Window* and *Ollama Keep Alive*. +2. Link the connection under *Settings > Technical > AI > AI Document + Extraction* in the *AI Connection* field (developer mode is required to see + the Technical menu). The partner match threshold can also be set there. + +The extraction flow is unchanged: upload the invoice, then click **Extract with +AI**. 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..7b964215 --- /dev/null +++ b/ai_document_extraction/security/ir.model.access.csv @@ -0,0 +1,4 @@ +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 +access_ai_document_extraction_settings,access.ai.document.extraction.settings,model_ai_document_extraction_settings,base.group_system,1,1,1,1 diff --git a/ai_document_extraction/services/__init__.py b/ai_document_extraction/services/__init__.py new file mode 100644 index 00000000..9e942498 --- /dev/null +++ b/ai_document_extraction/services/__init__.py @@ -0,0 +1 @@ +from . import ai_openai_compatible_client, llm_extractor diff --git a/ai_document_extraction/services/ai_openai_compatible_client.py b/ai_document_extraction/services/ai_openai_compatible_client.py new file mode 100644 index 00000000..8c4f70c4 --- /dev/null +++ b/ai_document_extraction/services/ai_openai_compatible_client.py @@ -0,0 +1,70 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import requests + +from odoo.addons.ai_connection.client import AiConnectionClient + + +class AiOpenAICompatibleClient(AiConnectionClient): + """OpenAI-compatible chat completions client (OpenRouter, Ollama, ...). + + The message list built by the caller is forwarded as-is; provider-specific + options (num_ctx/keep_alive) are only added when the URL points to Ollama. + """ + + def __init__( + self, + url, + model, + api_key="", + num_ctx=None, + keep_alive=None, + timeout=300, + ): + super().__init__() + self.url = url + self.model = model + self.api_key = api_key or "" + self.num_ctx = num_ctx + self.keep_alive = keep_alive + self.timeout = timeout + + @property + def _is_ollama(self): + return "ollama" in (self.url or "").lower() + + def handle_message(self, messages=None, temperature=None, **kwargs): + """Post the messages to /chat/completions and return the raw content.""" + payload = { + "model": self.model, + "messages": messages or [], + "temperature": temperature if temperature is not None else 0.0, + "stream": False, + } + if self._is_ollama: + if self.num_ctx: + payload["options"] = {"num_ctx": int(self.num_ctx)} + if self.keep_alive: + payload["keep_alive"] = self.keep_alive + headers = {} + if self.api_key and self.api_key != "dummy": + headers["Authorization"] = f"Bearer {self.api_key}" + url = f"{self.url.rstrip('/')}/chat/completions" + last_error = None + for _attempt in range(2): + try: + response = requests.post( + url, json=payload, headers=headers, timeout=self.timeout + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + if not (content or "").strip(): + raise ValueError("The vision model returned an empty response.") + return { + "message": {"role": "assistant", "content": content}, + "tool_calls": [], + } + except ValueError as error: + last_error = error + raise last_error diff --git a/ai_document_extraction/services/llm_extractor.py b/ai_document_extraction/services/llm_extractor.py new file mode 100644 index 00000000..ecefc316 --- /dev/null +++ b/ai_document_extraction/services/llm_extractor.py @@ -0,0 +1,220 @@ +# 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 image 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 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", +} + +_LEGAL_SUFFIXES = ( + "a.ş.", + "a.s.", + "ltd.", + "ltd", + "gmbh", + "inc.", + "s.l.", + "s.a.", + "b.v.", + "sarl", + "llc", + "corp.", + "co.", +) + + +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 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() + # Find the first valid JSON object, ignoring leading/trailing noise (e.g. + # "Sure! Here is the JSON:" or trailing prose with extra braces). + 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): + """Drop a reference that is empty or looks like a URL/path, not a printed number. + + Real receipt numbers can legitimately be long hex codes, so we no longer + reject hashes; we only reject clearly non-business strings. + """ + 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 candidate.lower() in _MODEL_NAMES or ( + " " not in candidate + and not any(candidate.lower().endswith(s) for s in _LEGAL_SUFFIXES) + ): + 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/index.html b/ai_document_extraction/static/description/index.html new file mode 100644 index 00000000..cb5e736a --- /dev/null +++ b/ai_document_extraction/static/description/index.html @@ -0,0 +1,513 @@ + + + + + +AI Document Extraction + + + +
+ + + +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, +date and amounts) from uploaded PDF, JPG or PNG documents by sending the +rendered document image to an OpenAI-compatible vision LLM (e.g. Ollama +running qwen3-vl or a cloud provider such as OpenRouter) that +returns a strict JSON payload.

+

The result is applied to a draft vendor bill (account.move): +partner, date, reference and a single amount line are set automatically. +Processing runs in the background through queue_job so the user +interface never blocks. If the extracted partner name cannot be matched, +a wizard lets the user pick or create the partner.

+

The LLM is instructed to ignore logo/slogan texts found in the document +header (e.g. a company name drawn inside a logo), to never compute +missing values, and to output null for anything it cannot read.

+
+

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.

+
+
+

Installation

+

To install and run this module you need the following Python packages +(installed with pip):

+
    +
  • Pillow
  • +
  • pdf2image
  • +
  • rapidfuzz
  • +
  • requests
  • +
+

And the system package:

+
    +
  • poppler-utils (required by pdf2image to render PDFs)
  • +
+

A running OpenAI-compatible chat completions endpoint with a vision +model is required, for example Ollama (http://ollama:11434/v1) +running a vision model such as qwen3-vl, or a cloud provider such as +OpenRouter (https://openrouter.ai/api/v1).

+
+
+

Usage

+
    +
  1. Go to Accounting > Vendors > Bills and create a draft vendor bill +(or open an existing draft one).
  2. +
  3. Attach the invoice PDF or image to the chatter.
  4. +
  5. Click Extract with AI. The invoice is processed in the +background.
  6. +
  7. When the AI Extraction State becomes Done, check the extracted +values. The partner is set automatically when a match is found.
  8. +
  9. If the partner could not be matched, click Review Extraction and +pick or create the partner in the wizard.
  10. +
+

Configure the AI backend as follows:

+
    +
  1. Create an AI Connection under Settings > AI > AI Connection with +kind OpenAI-compatible:
      +
    • Set the URL to a cloud provider such as +https://openrouter.ai/api/v1 or to a local Ollama server like +http://localhost:11434/v1.
    • +
    • Set the model, e.g. qwen/qwen3-vl-32b-instruct for OpenRouter +or qwen3-vl:8b for Ollama.
    • +
    • Set the API key for cloud providers only (leave it empty for local +Ollama) and set the temperature to 0. For Ollama you can also set +the Ollama Context Window and Ollama Keep Alive.
    • +
    +
  2. +
  3. Link the connection under Settings > Technical > AI > AI Document +Extraction in the AI Connection field (developer mode is required +to see the Technical menu). The partner match threshold can also be +set there.
  4. +
+

The extraction flow is unchanged: upload the invoice, then click +Extract with AI.

+
+
+

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/static/src/account_move_form_ai_extraction.js b/ai_document_extraction/static/src/account_move_form_ai_extraction.js new file mode 100644 index 00000000..28a1c96e --- /dev/null +++ b/ai_document_extraction/static/src/account_move_form_ai_extraction.js @@ -0,0 +1,64 @@ +/** @odoo-module **/ + +import {onWillUnmount, useEffect} from "@odoo/owl"; +import {FormController} from "@web/views/form/form_controller"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +const NOTIFICATION_TYPE = "ai_document_extraction"; +const CHANNEL_PREFIX = "ai_document_extraction.move."; + +patch(FormController.prototype, { + setup() { + super.setup(...arguments); + if (this.props.resModel !== "account.move") { + return; + } + this._aiBusService = useService("bus_service"); + this._aiHandler = this._aiHandler.bind(this); + this._aiSubscribedResId = null; + useEffect( + () => { + this._aiSyncSubscription(this.model.root?.resId); + }, + () => [this.model.root?.resId] + ); + onWillUnmount(() => this._aiUnsubscribe()); + }, + + _aiSyncSubscription(resId) { + if (resId === this._aiSubscribedResId) { + return; + } + this._aiUnsubscribe(); + this._aiSubscribedResId = resId; + if (!resId) { + return; + } + this._aiBusService + .addChannel(`${CHANNEL_PREFIX}${resId}`) + .catch(() => undefined); + this._aiBusService.subscribe(NOTIFICATION_TYPE, this._aiHandler); + }, + + _aiUnsubscribe() { + if (!this._aiSubscribedResId) { + return; + } + this._aiBusService.deleteChannel(`${CHANNEL_PREFIX}${this._aiSubscribedResId}`); + this._aiBusService.unsubscribe(NOTIFICATION_TYPE, this._aiHandler); + this._aiSubscribedResId = null; + }, + + async _aiHandler(payload) { + if (!this.model.root) { + return; + } + if ( + payload.move_id === this.model.root.resId && + !(await this.model.root.isDirty()) + ) { + this.model.load(); + } + }, +}); 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/test_extraction.py b/ai_document_extraction/tests/test_extraction.py new file mode 100644 index 00000000..6f672f67 --- /dev/null +++ b/ai_document_extraction/tests/test_extraction.py @@ -0,0 +1,1166 @@ +# Copyright 2026 VSL +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import os +from unittest import mock + +from odoo.tests import TransactionCase + +from ..services import llm_extractor + + +class TestLlmExtractor(TransactionCase): + def test_parse_json_from_noisy_content(self): + from ..services import llm_extractor + + content = ( + "Sure! Here is the JSON:\n" + '{"partner_name": "Voslo Lojistik", "invoice_number": "FT-123", ' + '"invoice_date": "2023-10-25", "amount_untaxed": 100.0, ' + '"amount_tax": 18.0, "amount_total": 118.0, "currency": "TRY"}' + ) + data = llm_extractor._parse_json_response(content) + self.assertEqual(data["partner_name"], "Voslo Lojistik") + self.assertEqual(data["amount_total"], 118.0) + + def test_parse_json_missing_fields_defaults_null(self): + from ..services import llm_extractor + + data = llm_extractor._parse_json_response('{"invoice_number": "X1"}') + for field in llm_extractor.EXPECTED_FIELDS: + self.assertIn(field, data) + + def test_parse_defaults_payment_reference_to_none(self): + from ..services import llm_extractor + + data = llm_extractor._parse_json_response('{"invoice_number": "X1"}') + self.assertIsNone(data["payment_reference"]) + + def test_parse_json_raises_without_object(self): + from ..services import llm_extractor + + with self.assertRaises(ValueError): + llm_extractor._parse_json_response("I am sorry, I cannot do that.") + + def test_parse_json_ignores_trailing_braces(self): + from ..services import llm_extractor + + content = '{"invoice_number": "FT-1"} but note {this} and more' + data = llm_extractor._parse_json_response(content) + self.assertEqual(data["invoice_number"], "FT-1") + + def test_parse_json_code_fence(self): + from ..services import llm_extractor + + content = '```json\n{"invoice_number": "FT-2"}\n```' + data = llm_extractor._parse_json_response(content) + self.assertEqual(data["invoice_number"], "FT-2") + + def test_parse_json_raises_for_list(self): + from ..services import llm_extractor + + with self.assertRaises(ValueError): + llm_extractor._parse_json_response("[1, 2, 3]") + + def test_prompt_asks_for_receipt_and_payment_reference(self): + from ..services import llm_extractor + + self.assertIn("Receipt", llm_extractor.SYSTEM_PROMPT) + self.assertIn("payment_reference", llm_extractor.SYSTEM_PROMPT) + self.assertNotIn("never a URL or hash", llm_extractor.SYSTEM_PROMPT) + + def test_validate_keeps_hash_invoice_number(self): + from ..services import llm_extractor + + data = { + "invoice_number": "2054148b703b43e690b244ff544d2a9f", + "partner_name": "VOSLO LOJISTIK A.S.", + "invoice_date": "2023-10-25", + } + result = llm_extractor._validate_data(data) + self.assertEqual(result["invoice_number"], "2054148b703b43e690b244ff544d2a9f") + + def test_validate_rejects_url_invoice_number(self): + from ..services import llm_extractor + + result = llm_extractor._validate_data( + {"invoice_number": "https://files.example.com/invoice.pdf"} + ) + self.assertIsNone(result["invoice_number"]) + + def test_validate_rejects_url_payment_reference(self): + from ..services import llm_extractor + + result = llm_extractor._validate_data( + {"payment_reference": "https://files.example.com/pay.pdf"} + ) + self.assertIsNone(result["payment_reference"]) + + def test_validate_rejects_model_name_partner(self): + from ..services import llm_extractor + + result = llm_extractor._validate_data( + {"partner_name": "DeepSeek", "invoice_date": "2023-10-25"} + ) + self.assertIsNone(result["partner_name"]) + + def test_validate_rejects_single_token_logo_partner(self): + from ..services import llm_extractor + + result = llm_extractor._validate_data({"partner_name": "voslo"}) + self.assertIsNone(result["partner_name"]) + + def test_validate_keeps_full_company_issuer(self): + from ..services import llm_extractor + + result = llm_extractor._validate_data({"partner_name": "VOSLO LOJISTIK A.S."}) + self.assertEqual(result["partner_name"], "VOSLO LOJISTIK A.S.") + + def test_validate_rejects_out_of_range_date(self): + from ..services import llm_extractor + + result = llm_extractor._validate_data({"invoice_date": "2099-01-01"}) + self.assertIsNone(result["invoice_date"]) + + def test_validate_rejects_bad_date_format(self): + from ..services import llm_extractor + + result = llm_extractor._validate_data({"invoice_date": "25.10.2023"}) + self.assertIsNone(result["invoice_date"]) + + def test_validate_drops_unknown_currency(self): + from ..services import llm_extractor + + result = llm_extractor._validate_data( + {"currency": "ZZZ"}, available_currencies=["TRY", "USD"] + ) + self.assertIsNone(result["currency"]) + + def test_validate_removes_unknown_tax_rate(self): + from ..services import llm_extractor + + data = { + "lines": [ + {"name": "Nakliye", "tax_rate": 18}, + {"name": "Depolama", "tax_rate": 20}, + ] + } + result = llm_extractor._validate_data( + data, + available_taxes=[ + {"id": 34, "name": "20%", "amount": 20.0, "amount_type": "percent"} + ], + ) + self.assertNotIn("tax_rate", result["lines"][0]) + self.assertEqual(result["lines"][1]["tax_rate"], 20) + + def test_validate_removes_unknown_tax_id(self): + from ..services import llm_extractor + + data = {"lines": [{"name": "Nakliye", "tax_id": 999}]} + result = llm_extractor._validate_data( + data, + available_taxes=[ + {"id": 34, "name": "20%", "amount": 20.0, "amount_type": "percent"} + ], + ) + self.assertNotIn("tax_id", result["lines"][0]) + + +class TestAccountMoveExtraction(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + {"name": "Voslo Lojistik", "is_company": True} + ) + cls.move = cls.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": cls.partner.id, + } + ) + cls.connection = cls.env["ai.connection"].create( + { + "name": "Test Connection", + "kind": "openai_compatible", + "url": "http://ollama:11434/v1", + "model": "qwen3-vl:8b", + "temperature": 0, + } + ) + cls.env["ir.config_parameter"].sudo().set_param( + "ai_document_extraction.ai_connection_id", str(cls.connection.id) + ) + + def _attach(self): + png = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" + "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + return self.env["ir.attachment"].create( + { + "name": "invoice.png", + "datas": png, + "mimetype": "image/png", + "res_model": "account.move", + "res_id": self.move.id, + } + ) + + def test_match_partner_exact(self): + match = self.move._match_partner("Voslo Lojistik", 85) + self.assertEqual(match, self.partner) + + def test_match_partner_below_threshold(self): + match = self.move._match_partner("Bilinmeyen Firma", 85) + self.assertIsNone(match) + + def test_new_vendor_bill_defaults_invoice_date(self): + from odoo import fields as odoo_fields + + move = self.env["account.move"].create({"move_type": "in_invoice"}) + self.assertEqual( + move.invoice_date, + odoo_fields.Date.context_today(self.env["account.move"]), + ) + + def test_apply_extraction(self): + data = { + "partner_name": "Voslo Lojistik", + "invoice_number": "FT-123", + "invoice_date": "2023-10-25", + "amount_untaxed": 100.0, + "amount_tax": 18.0, + "amount_total": 118.0, + "currency": "TRY", + } + matched = self.move._apply_extraction(data) + self.assertEqual(self.move.partner_id, self.partner) + self.assertEqual(self.move.ref, "FT-123") + self.assertEqual(str(self.move.invoice_date), "2023-10-25") + self.assertTrue(self.move.line_ids) + self.assertEqual(self.move.ai_extracted_tax, 18.0) + self.assertIsNotNone(matched) + + def test_apply_extraction_with_lines(self): + data = { + "partner_name": "Voslo Lojistik", + "invoice_number": "FT-456", + "amount_tax": 18.0, + "amount_total": 118.0, + "lines": [ + {"name": "Nakliye Hizmeti", "quantity": 1, "price_unit": 90.0}, + {"name": "Depolama", "quantity": 2, "price_unit": 5.0}, + ], + } + self.move._apply_extraction(data) + line_names = [line.name for line in self.move.line_ids if line.price_subtotal] + self.assertIn("Nakliye Hizmeti", line_names) + self.assertIn("Depolama", line_names) + + def test_apply_sets_payment_reference_field(self): + data = { + "invoice_number": "FT-123", + "payment_reference": "PAY-XYZ", + } + self.move._apply_extraction(data) + self.assertEqual(self.move.ref, "FT-123") + self.assertEqual(self.move.payment_reference, "PAY-XYZ") + + def test_apply_falls_back_to_payment_reference(self): + data = { + "invoice_number": None, + "payment_reference": "9e06cda8-3e39-492f-9863-11c6b4a0ad3e", + } + self.move._apply_extraction(data) + self.assertEqual(self.move.ref, "9e06cda8-3e39-492f-9863-11c6b4a0ad3e") + self.assertEqual( + self.move.payment_reference, "9e06cda8-3e39-492f-9863-11c6b4a0ad3e" + ) + bodies = [message.body or "" for message in self.move.message_ids] + self.assertTrue(any("payment reference" in body.lower() for body in bodies)) + + def test_apply_no_fallback_note_when_invoice_number_present(self): + data = {"invoice_number": "FT-123", "payment_reference": "PAY-XYZ"} + self.move._apply_extraction(data) + bodies = [message.body or "" for message in self.move.message_ids] + self.assertFalse(any("payment reference" in body.lower() for body in bodies)) + + def test_settings_default_get_loads_connection(self): + settings = self.env["ai.document.extraction.settings"].create({}) + self.assertEqual(settings.ai_connection_id, self.connection) + self.assertEqual(settings.fuzzy_match_threshold, 85) + + def test_settings_default_get_false_when_no_connection(self): + self.env["ir.config_parameter"].sudo().set_param( + "ai_document_extraction.ai_connection_id", "0" + ) + settings = self.env["ai.document.extraction.settings"].create({}) + self.assertFalse(settings.ai_connection_id) + + def test_settings_save_writes_params(self): + self.env["ir.config_parameter"].sudo().set_param( + "ai_document_extraction.ai_connection_id", "0" + ) + settings = self.env["ai.document.extraction.settings"].create( + { + "ai_connection_id": self.connection.id, + "fuzzy_match_threshold": 90, + } + ) + settings.action_save() + self.assertEqual( + self.env["ir.config_parameter"] + .sudo() + .get_param("ai_document_extraction.ai_connection_id"), + str(self.connection.id), + ) + self.assertEqual( + self.env["ir.config_parameter"] + .sudo() + .get_param("ai_document_extraction.fuzzy_match_threshold"), + "90", + ) + + def test_ai_settings_defaults(self): + settings = self.move._ai_settings() + self.assertEqual(settings["ai_connection_id"], self.connection.id) + self.assertEqual(settings["fuzzy_match_threshold"], 85) + + def test_ai_vision_message_builds_png_for_ollama(self): + import base64 + + path = "/tmp/ai_vision_msg.png" + png = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" + "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + with open(path, "wb") as handle: + handle.write(base64.b64decode(png)) + try: + message = self.move._ai_vision_message(path, self.connection) + finally: + if os.path.exists(path): + os.unlink(path) + self.assertEqual(message["role"], "user") + content = message["content"] + self.assertEqual(content[0]["type"], "image_url") + self.assertIn("data:image/png;base64,", content[0]["image_url"]["url"]) + self.assertEqual(content[1]["type"], "text") + + def test_ai_vision_message_uses_jpeg_for_cloud(self): + from PIL import Image + + path = "/tmp/ai_vision_msg_cloud.png" + Image.new("RGB", (100, 100), "white").save(path) + cloud = self.env["ai.connection"].create( + { + "name": "Cloud", + "kind": "openai_compatible", + "url": "https://openrouter.ai/api/v1", + "model": "qwen/qwen3-vl-32b-instruct", + } + ) + try: + message = self.move._ai_vision_message(path, cloud) + finally: + if os.path.exists(path): + os.unlink(path) + self.assertIn( + "data:image/jpeg;base64,", message["content"][0]["image_url"]["url"] + ) + + def test_job_happy_path_uses_connection(self): + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + attachment = self._attach() + content = ( + '{"partner_name": "Voslo Lojistik", "invoice_number": "FT-123", ' + '"invoice_date": "2023-10-25", "amount_untaxed": 100.0, ' + '"amount_tax": 18.0, "amount_total": 118.0, "currency": "TRY"}' + ) + with mock.patch.object( + AiConnection, "_run", return_value=(content, 0, 0, 1) + ) as run_mock: + self.move._extract_with_ai_job(attachment.id) + self.assertEqual(self.move.ai_extraction_state, "done") + self.assertEqual(self.move.ref, "FT-123") + self.assertIn("partner_name", self.move.ai_raw_extraction) + self.assertEqual( + run_mock.call_args.kwargs["system_prompt"], llm_extractor.SYSTEM_PROMPT + ) + self.assertEqual(run_mock.call_args.kwargs["messages"][0]["role"], "user") + + def test_job_error_path_uses_connection(self): + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + attachment = self._attach() + with mock.patch.object(AiConnection, "_run", side_effect=RuntimeError("boom")): + self.move._extract_with_ai_job(attachment.id) + self.assertEqual(self.move.ai_extraction_state, "error") + + def test_ai_extract_data_retries_once_on_unparseable(self): + import base64 + + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + path = "/tmp/ai_extract_data_retry.png" + png = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" + "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + with open(path, "wb") as handle: + handle.write(base64.b64decode(png)) + try: + with mock.patch.object( + AiConnection, + "_run", + side_effect=[ + ("I am sorry, I cannot do that.", 0, 0, 1), + ('{"invoice_number": "FT-1"}', 0, 0, 1), + ], + ) as run_mock: + data = self.move._ai_extract_data(self.connection, path) + finally: + if os.path.exists(path): + os.unlink(path) + self.assertEqual(run_mock.call_count, 2) + self.assertEqual(data["invoice_number"], "FT-1") + + def test_ai_extract_data_raises_after_both_attempts(self): + import base64 + + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + path = "/tmp/ai_extract_data_both.png" + png = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" + "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + with open(path, "wb") as handle: + handle.write(base64.b64decode(png)) + try: + with mock.patch.object( + AiConnection, + "_run", + side_effect=[("not json", 0, 0, 1), ("still not json", 0, 0, 1)], + ) as run_mock: + with self.assertRaises(ValueError): + self.move._ai_extract_data(self.connection, path) + finally: + if os.path.exists(path): + os.unlink(path) + self.assertEqual(run_mock.call_count, 2) + + def test_ai_extract_data_does_not_retry_non_parse_errors(self): + import base64 + + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + path = "/tmp/ai_extract_data_runtime.png" + png = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" + "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + with open(path, "wb") as handle: + handle.write(base64.b64decode(png)) + try: + with mock.patch.object( + AiConnection, "_run", side_effect=RuntimeError("boom") + ) as run_mock: + with self.assertRaises(RuntimeError): + self.move._ai_extract_data(self.connection, path) + finally: + if os.path.exists(path): + os.unlink(path) + self.assertEqual(run_mock.call_count, 1) + + def test_ai_connection_raises_when_not_configured(self): + from odoo.exceptions import UserError + + self.env["ir.config_parameter"].sudo().set_param( + "ai_document_extraction.ai_connection_id", "0" + ) + with self.assertRaises(UserError): + self.move._ai_connection() + + def test_job_works_for_non_admin_user(self): + from odoo.tests.common import new_test_user + + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + user = new_test_user( + self.env, + login="accountant_ai_test", + groups="account.group_account_invoice,base.group_user", + ) + move = ( + self.env["account.move"].with_user(user).create({"move_type": "in_invoice"}) + ) + attachment = self.env["ir.attachment"].create( + { + "name": "invoice.png", + "datas": ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ), + "mimetype": "image/png", + "res_model": "account.move", + "res_id": move.id, + } + ) + with mock.patch.object( + AiConnection, "_run", return_value=('{"invoice_number": "FT-1"}', 0, 0, 1) + ): + move.with_user(user)._extract_with_ai_job(attachment.id) + self.assertEqual(move.ai_extraction_state, "done") + + def test_ai_resize_caps_at_1280(self): + from PIL import Image + + path = "/tmp/ai_resize_test.png" + Image.new("RGB", (2000, 2600), "white").save(path) + try: + result = self.move._ai_resize_image(path) + width, height = Image.open(result).size + self.assertLessEqual(max(width, height), 1280) + finally: + for candidate in (path, f"{path}.resized.png"): + if os.path.exists(candidate): + os.unlink(candidate) + + def test_apply_extraction_sets_currency(self): + data = {"currency": "USD"} + self.move._apply_extraction(data) + self.assertEqual(self.move.currency_id.name, "USD") + + def test_apply_extraction_ignores_unknown_currency(self): + data = {"currency": "ZZZ"} + self.move._apply_extraction(data) + self.assertEqual(self.move.currency_id, self.move.company_id.currency_id) + + def test_apply_extraction_applies_line_tax(self): + tax = self.env["account.tax"].create( + { + "name": "AI Test 20%", + "amount": 20.0, + "amount_type": "percent", + "type_tax_use": "purchase", + "company_id": self.env.company.id, + } + ) + data = { + "lines": [ + { + "name": "Nakliye Hizmeti", + "quantity": 1, + "price_unit": 100.0, + "tax_rate": 20.0, + } + ] + } + self.move._apply_extraction(data) + line = self.move.line_ids.filtered(lambda line: line.price_subtotal) + self.assertEqual(line.tax_ids, tax) + self.assertEqual(self.move.amount_tax, 20.0) + self.assertEqual(self.move.amount_total, 120.0) + + def test_apply_extraction_line_untaxed_when_tax_unknown(self): + data = { + "lines": [ + { + "name": "Nakliye Hizmeti", + "quantity": 1, + "price_unit": 100.0, + "tax_rate": 18.0, + } + ] + } + self.move._apply_extraction(data) + line = self.move.line_ids.filtered(lambda line: line.price_subtotal) + self.assertFalse(line.tax_ids) + + def test_apply_extraction_fallback_line_uses_description(self): + data = {"amount_untaxed": 100.0, "description": "Nakliye Hizmeti"} + self.move._apply_extraction(data) + line = self.move.line_ids.filtered(lambda line: line.price_subtotal) + self.assertEqual(len(line), 1) + self.assertEqual(line.name, "Nakliye Hizmeti") + self.assertEqual(line.price_subtotal, 100.0) + + def test_apply_extraction_never_uses_default_line_name(self): + data = { + "lines": [{"name": "Nakliye Hizmeti", "quantity": 1, "price_unit": 90.0}], + "amount_untaxed": 90.0, + } + self.move._apply_extraction(data) + self.assertFalse( + self.move.line_ids.filtered(lambda line: line.name == "AI extracted amount") + ) + + def test_apply_extraction_warns_when_date_missing(self): + self.move._apply_extraction({"lines": []}) + bodies = [message.body or "" for message in self.move.message_ids] + self.assertTrue(any("invoice date" in body.lower() for body in bodies)) + + def test_upload_image_posts_informative_message(self): + attachment = self._attach() + journal = self.env["account.journal"].search( + [("type", "=", "purchase")], limit=1 + ) + records = ( + self.env["account.move"] + .with_context(default_journal_id=journal.id) + ._create_records_from_attachments(attachment) + ) + move = records[0] + bodies = [message.body or "" for message in move.message_ids] + self.assertFalse( + any("error while importing" in body.lower() for body in bodies) + ) + self.assertTrue(any("Extract with AI" in body for body in bodies)) + + def test_upload_non_processable_file_keeps_import_error(self): + import base64 + + attachment = self.env["ir.attachment"].create( + { + "name": "edifact.xml", + "datas": base64.b64encode(b""), + "mimetype": "application/xml", + } + ) + journal = self.env["account.journal"].search( + [("type", "=", "purchase")], limit=1 + ) + records = ( + self.env["account.move"] + .with_context(default_journal_id=journal.id) + ._create_records_from_attachments(attachment) + ) + move = records[0] + bodies = [message.body or "" for message in move.message_ids] + self.assertTrue(any("error while importing" in body.lower() for body in bodies)) + + def test_ai_is_processable_ignores_duplicate_suffix(self): + self.assertTrue(self.move._ai_is_processable("receipt_90b5ba57.pdf (1)")) + self.assertTrue(self.move._ai_is_processable("invoice.PNG (2)")) + self.assertFalse(self.move._ai_is_processable("edifact.xml")) + self.assertFalse(self.move._ai_is_processable("archive")) + + def test_upload_duplicate_suffixed_file_posts_informative_message(self): + import base64 + + attachment = self.env["ir.attachment"].create( + { + "name": "receipt_90b5ba57-31c0-4373-a61f-d4b84adea60e.pdf (1)", + "datas": base64.b64encode(b"%PDF-1.4 fake receipt"), + "mimetype": "application/pdf", + } + ) + journal = self.env["account.journal"].search( + [("type", "=", "purchase")], limit=1 + ) + records = ( + self.env["account.move"] + .with_context(default_journal_id=journal.id) + ._create_records_from_attachments(attachment) + ) + move = records[0] + bodies = [message.body or "" for message in move.message_ids] + self.assertFalse( + any("error while importing" in body.lower() for body in bodies) + ) + self.assertTrue(any("Extract with AI" in body for body in bodies)) + + def test_ai_prepare_image_recognizes_duplicate_suffixed_pdf(self): + import base64 + import io + + from PIL import Image + + png = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" + "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + attachment = self.env["ir.attachment"].create( + { + "name": "receipt_90b5ba57-31c0-4373-a61f-d4b84adea60e.pdf (1)", + "datas": base64.b64encode(b"%PDF-1.4 fake receipt"), + "mimetype": "application/pdf", + } + ) + with mock.patch("pdf2image.convert_from_path") as mock_convert: + mock_convert.return_value = [Image.open(io.BytesIO(base64.b64decode(png)))] + path = self.move._ai_prepare_image(attachment) + self.assertTrue(path.endswith(".png")) + mock_convert.assert_called_once() + + def test_action_extract_with_ai_allows_customer_invoice(self): + move = self.env["account.move"].create({"move_type": "out_invoice"}) + self.env["ir.attachment"].create( + { + "name": "invoice.png", + "datas": ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ), + "mimetype": "image/png", + "res_model": "account.move", + "res_id": move.id, + } + ) + with mock.patch.object(type(move), "with_delay", return_value=mock.Mock()): + move.action_extract_with_ai() + self.assertEqual(move.ai_extraction_state, "processing") + + def test_job_stores_processed_image(self): + import base64 + + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + png_path = "/tmp/ai_processed_test.png" + png = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8B" + "QDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + with open(png_path, "wb") as handle: + handle.write(base64.b64decode(png)) + attachment = self._attach() + with mock.patch.object( + AiConnection, + "_run", + return_value=( + json.dumps({"partner_name": "Voslo Lojistik", "lines": []}), + 0, + 0, + 1, + ), + ): + self.move._extract_with_ai_job(attachment.id) + if os.path.exists(png_path): + os.unlink(png_path) + stored = self.env["ir.attachment"].search( + [ + ("res_model", "=", "account.move"), + ("res_id", "=", self.move.id), + ("name", "like", "-ai-processed.png"), + ] + ) + self.assertTrue(stored) + self.assertEqual(stored.mimetype, "image/png") + + def test_job_happy_path(self): + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + attachment = self._attach() + with mock.patch.object( + AiConnection, + "_run", + return_value=( + json.dumps( + { + "partner_name": "Voslo Lojistik", + "invoice_number": "FT-123", + "invoice_date": "2023-10-25", + "amount_untaxed": 100.0, + "amount_tax": 18.0, + "amount_total": 118.0, + "currency": "TRY", + } + ), + 0, + 0, + 1, + ), + ): + self.move._extract_with_ai_job(attachment.id) + self.assertEqual(self.move.ai_extraction_state, "done") + self.assertEqual(self.move.ref, "FT-123") + self.assertIn("partner_name", self.move.ai_raw_extraction) + + def test_job_error_path(self): + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + attachment = self._attach() + with mock.patch.object(AiConnection, "_run", side_effect=RuntimeError("boom")): + self.move._extract_with_ai_job(attachment.id) + self.assertEqual(self.move.ai_extraction_state, "error") + + def test_job_notifies_bus_on_done(self): + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + attachment = self._attach() + with mock.patch.object( + AiConnection, + "_run", + return_value=( + json.dumps( + { + "partner_name": "Voslo Lojistik", + "invoice_number": "FT-123", + "invoice_date": "2023-10-25", + "amount_untaxed": 100.0, + "amount_tax": 18.0, + "amount_total": 118.0, + } + ), + 0, + 0, + 1, + ), + ): + with mock.patch.object( + type(self.env["bus.bus"]), "_sendone" + ) as mock_sendone: + self.move._extract_with_ai_job(attachment.id) + mock_sendone.assert_called_once_with( + f"ai_document_extraction.move.{self.move.id}", + "ai_document_extraction", + {"move_id": self.move.id, "state": "done"}, + ) + + def test_job_notifies_bus_on_error(self): + from odoo.addons.ai_connection.models.ai_connection import AiConnection + + attachment = self._attach() + with mock.patch.object(AiConnection, "_run", side_effect=RuntimeError("boom")): + with mock.patch.object( + type(self.env["bus.bus"]), "_sendone" + ) as mock_sendone: + self.move._extract_with_ai_job(attachment.id) + mock_sendone.assert_called_once_with( + f"ai_document_extraction.move.{self.move.id}", + "ai_document_extraction", + {"move_id": self.move.id, "state": "error"}, + ) + + def test_apply_extraction_keeps_partner_if_not_matched(self): + data = { + "partner_name": "Var Olmayan Firma", + "invoice_number": "FT-999", + } + self.move._apply_extraction(data) + self.assertEqual(self.move.partner_id, self.partner) + self.assertEqual(self.move.ref, "FT-999") + + def test_action_extract_with_ai_requires_draft(self): + from odoo.exceptions import UserError + + self.move.invoice_date = "2023-10-25" + account = self.env["account.account"].search( + [("internal_group", "=", "expense")], limit=1 + ) + self.move.write( + { + "line_ids": [ + ( + 0, + 0, + { + "name": "Test", + "account_id": account.id, + "quantity": 1, + "price_unit": 10.0, + }, + ) + ] + } + ) + self.move.with_context(skip_invoice_sync=True).action_post() + with self.assertRaises(UserError): + self.move.action_extract_with_ai() + + def test_ai_buttons_hidden_on_non_draft(self): + from lxml import etree + + view = self.env.ref("ai_document_extraction.account_move_form_ai_extraction") + arch = etree.fromstring(view.arch) + buttons = { + button.get("name"): button.get("invisible", "") + for button in arch.iter("button") + if button.get("name") + in ("action_extract_with_ai", "action_review_extraction") + } + self.assertEqual( + set(buttons), + {"action_extract_with_ai", "action_review_extraction"}, + ) + for name, invisible in buttons.items(): + self.assertIn( + "state != 'draft'", + invisible, + f"{name} should be hidden on non-draft moves", + ) + + def test_action_extract_with_ai_requires_vendor_bill(self): + from odoo.exceptions import UserError + + move = self.env["account.move"].create({"move_type": "out_invoice"}) + with self.assertRaises(UserError): + move.action_extract_with_ai() + + def test_action_extract_with_ai_requires_attachment(self): + from odoo.exceptions import UserError + + move = self.env["account.move"].create({"move_type": "in_invoice"}) + with self.assertRaises(UserError): + move.action_extract_with_ai() + + def test_action_extract_with_ai_enqueues(self): + move = self.env["account.move"].create({"move_type": "in_invoice"}) + attachment = self.env["ir.attachment"].create( + { + "name": "invoice.png", + "datas": ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ), + "mimetype": "image/png", + "res_model": "account.move", + "res_id": move.id, + } + ) + with mock.patch.object( + type(move), "with_delay", return_value=mock.Mock() + ) as delay_mock: + move.action_extract_with_ai() + self.assertEqual(move.ai_extraction_state, "processing") + delay_mock.return_value._extract_with_ai_job.assert_called_once_with( + attachment.id + ) + + +class TestExtractionWizard(TransactionCase): + def test_wizard_apply_partner(self): + move = self.env["account.move"].create({"move_type": "in_invoice"}) + partner = self.env["res.partner"].create( + {"name": "Yeni Firma", "is_company": True} + ) + wizard = self.env["extraction.wizard"].create( + { + "move_id": move.id, + "extracted_partner_name": "Yeni Firma", + "partner_id": partner.id, + } + ) + result = wizard.action_apply() + self.assertEqual(move.partner_id, partner) + self.assertEqual(move.ai_extraction_state, "done") + self.assertEqual(result["res_id"], move.id) + + def test_wizard_apply_ignores_non_draft(self): + partner = self.env["res.partner"].create({"name": "Firma", "is_company": True}) + move = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": partner.id, + } + ) + move.invoice_date = "2023-10-25" + account = self.env["account.account"].search( + [("internal_group", "=", "expense")], limit=1 + ) + move.write( + { + "line_ids": [ + ( + 0, + 0, + { + "name": "Test", + "account_id": account.id, + "quantity": 1, + "price_unit": 10.0, + }, + ) + ] + } + ) + move.with_context(skip_invoice_sync=True).action_post() + wizard = self.env["extraction.wizard"].create( + { + "move_id": move.id, + "partner_id": partner.id, + } + ) + result = wizard.action_apply() + self.assertEqual(move.partner_id.id, partner.id) + # move is posted; do not change its state + self.assertEqual(result["type"], "ir.actions.act_window_close") + + def test_wizard_apply_empty_partner_keeps_existing(self): + move = self.env["account.move"].create({"move_type": "in_invoice"}) + existing = self.env["res.partner"].create( + {"name": "Mevcut Firma", "is_company": True} + ) + move.partner_id = existing.id + wizard = self.env["extraction.wizard"].create( + { + "move_id": move.id, + "extracted_partner_name": "Bilinmeyen", + } + ) + wizard.action_apply() + self.assertEqual(move.partner_id, existing) + self.assertEqual(move.ai_extraction_state, "done") + + def test_action_review_extraction_creates_wizard(self): + move = self.env["account.move"].create({"move_type": "in_invoice"}) + partner = self.env["res.partner"].create( + {"name": "Voslo Lojistik", "is_company": True} + ) + move.write( + { + "partner_id": partner.id, + "ai_raw_extraction": '{"partner_name": "Voslo Lojistik"}', + } + ) + result = move.action_review_extraction() + self.assertEqual(result["res_model"], "extraction.wizard") + self.assertEqual(result["target"], "new") + wizard = self.env["extraction.wizard"].browse(result["res_id"]) + self.assertEqual(wizard.move_id, move) + self.assertEqual(wizard.extracted_partner_name, "Voslo Lojistik") + self.assertEqual(wizard.partner_id, partner) + + +class TestAiConnection(TransactionCase): + def test_openai_compatible_defaults_temperature_to_zero(self): + connection = self.env["ai.connection"].create( + { + "name": "Deterministic", + "kind": "openai_compatible", + "url": "https://openrouter.ai/api/v1", + "model": "qwen/qwen3-vl-32b-instruct", + } + ) + self.assertEqual(connection.temperature, 0.0) + + def test_openai_compatible_kind_builds_client(self): + connection = self.env["ai.connection"].create( + { + "name": "OpenRouter", + "kind": "openai_compatible", + "url": "https://openrouter.ai/api/v1", + "model": "qwen/qwen3-vl-32b-instruct", + } + ) + client = connection._get_client_openai_compatible(None) + self.assertEqual(client.url, "https://openrouter.ai/api/v1") + self.assertEqual(client.model, "qwen/qwen3-vl-32b-instruct") + self.assertEqual(client.api_key, "") + + +class TestAiOpenAICompatibleClient(TransactionCase): + def _client(self, **kw): + defaults = { + "url": "https://openrouter.ai/api/v1", + "model": "qwen/qwen3-vl-32b-instruct", + } + defaults.update(kw) + from ..services.ai_openai_compatible_client import ( + AiOpenAICompatibleClient, + ) + + return AiOpenAICompatibleClient(**defaults) + + def _mock_response(self, content): + response = mock.Mock() + response.status_code = 200 + response.json.return_value = {"choices": [{"message": {"content": content}}]} + return response + + def test_handle_message_posts_openai_payload(self): + from ..services import ai_openai_compatible_client as client_mod + + response = self._mock_response('{"ok": 1}') + messages = [{"role": "user", "content": "hello"}] + with mock.patch.object( + client_mod.requests, "post", return_value=response + ) as post_mock: + result = self._client().handle_message(messages, temperature=0) + self.assertEqual(result["message"]["content"], '{"ok": 1}') + self.assertEqual(result["tool_calls"], []) + payload = post_mock.call_args.kwargs["json"] + self.assertEqual(payload["model"], "qwen/qwen3-vl-32b-instruct") + self.assertEqual(payload["temperature"], 0) + self.assertEqual(payload["messages"], messages) + self.assertNotIn("options", payload) + + def test_handle_message_sends_api_key(self): + from ..services import ai_openai_compatible_client as client_mod + + response = self._mock_response('{"ok": 1}') + with mock.patch.object( + client_mod.requests, "post", return_value=response + ) as post_mock: + self._client(api_key="secret").handle_message( + [{"role": "user", "content": "hi"}] + ) + self.assertEqual( + post_mock.call_args.kwargs["headers"]["Authorization"], "Bearer secret" + ) + + def test_handle_message_adds_ollama_options(self): + from ..services import ai_openai_compatible_client as client_mod + + response = self._mock_response('{"ok": 1}') + with mock.patch.object( + client_mod.requests, "post", return_value=response + ) as post_mock: + self._client( + url="http://ollama:11434/v1", num_ctx=32768, keep_alive="30m" + ).handle_message([{"role": "user", "content": "hi"}]) + payload = post_mock.call_args.kwargs["json"] + self.assertEqual(payload["options"], {"num_ctx": 32768}) + self.assertEqual(payload["keep_alive"], "30m") + + def test_handle_message_omits_ollama_options_on_cloud(self): + from ..services import ai_openai_compatible_client as client_mod + + response = self._mock_response('{"ok": 1}') + with mock.patch.object( + client_mod.requests, "post", return_value=response + ) as post_mock: + self._client(num_ctx=8192, keep_alive="30m").handle_message( + [{"role": "user", "content": "hi"}] + ) + payload = post_mock.call_args.kwargs["json"] + self.assertNotIn("options", payload) + self.assertNotIn("keep_alive", payload) + + def test_handle_message_retries_once_on_empty_content(self): + from ..services import ai_openai_compatible_client as client_mod + + with mock.patch.object( + client_mod.requests, + "post", + side_effect=[self._mock_response(""), self._mock_response('{"ok": 1}')], + ) as post_mock: + result = self._client().handle_message([{"role": "user", "content": "hi"}]) + self.assertEqual(post_mock.call_count, 2) + self.assertEqual(result["message"]["content"], '{"ok": 1}') + + def test_handle_message_raises_after_empty_retries(self): + from ..services import ai_openai_compatible_client as client_mod + + with mock.patch.object( + client_mod.requests, "post", return_value=self._mock_response("") + ): + with self.assertRaises(ValueError): + self._client().handle_message([{"role": "user", "content": "hi"}]) + + def test_handle_message_passes_timeout(self): + from ..services import ai_openai_compatible_client as client_mod + + response = self._mock_response('{"ok": 1}') + with mock.patch.object( + client_mod.requests, "post", return_value=response + ) as post_mock: + self._client(timeout=300).handle_message( + [{"role": "user", "content": "hi"}] + ) + self.assertEqual(post_mock.call_args.kwargs["timeout"], 300) 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..d74367c0 --- /dev/null +++ b/ai_document_extraction/views/account_move_views.xml @@ -0,0 +1,65 @@ + + + + account.move.form.ai.extraction + account.move + + + +